01 - Wprowadzenie
Więcej niż kolejny wrapper na OpenAI
Rynek IT zalała fala aplikacji "AI-powered", które w rzeczywistości są tylko cienkimi wrapperami na API od OpenAI. Zbudowanie prostego czatu z podstawowym systemem RAG (Retrieval-Augmented Generation) to dzisiaj poziom Junior+. Chciałem pójść znacznie dalej i zbudować prawdziwy System of Intelligence.
Jeśli czytałeś moje poprzednie case study o GroupNote, wiesz doskonale, że over-engineering i pułapka "jeszcze jednego ficzera" potrafią zabić świetny produkt, zanim ten w ogóle trafi na rynek.
[ założenie projektu ]
Dlaczego piłka nożna i Manchester City?
Potrzebowałem dziedziny, która jest gęsta od danych, wymaga głębokiej analityki, analizy przestrzennej i wyciągania kontekstowych wniosków. A ponieważ po godzinach sam gram w piłkę i uważnie śledzę ten sport, wybór nasunął się sam.
Premier League to obecnie najbardziej taktyczna i analityczna liga świata. Z kolei Manchester City pod wodzą Pepa Guardioli to synonim nowoczesnego, opartego na systemach i danych futbolu. To idealny grunt pod budowę wirtualnego członka sztabu szkoleniowego.
Co ostatecznie zbudowałem?
AI-Native Tactical Coach to nie jest kolejny chatbot, który wyrecytuje Ci biografię Erlinga Haalanda z Wikipedii. To zaawansowany ekosystem, w którym asystent:
Proaktywnie analizuje skauting przeciwnika i generuje wielowątkowe raporty,
Zna kontekst tego, na co aktualnie patrzysz w interfejsie aplikacji,
Przewiduje formacje i rozrysowuje „heatmapy”,
Posiada długoterminową pamięć, dzięki której uczy się Twoich preferencji taktycznych.
Aby osiągnąć taki poziom zaawansowania i płynności (UX), klasyczny monolit i zwykłe zapytania HTTP przestały wystarczać. Musiałem zbudować system, który działa w tle i nigdy nie każe użytkownikowi czekać na odpowiedź serwera.
02 - Architektura
Poliglotyczny ekosystem pod orkiestracją .NET Aspire
Projektowanie systemów AI-native niesie ze sobą specyficzne wyzwanie: jak połączyć stabilność i bezpieczeństwo transakcyjne świata .NET z elastycznością i bogatym ekosystemem AI, który żyje w Pythonie?
Moja odpowiedź to poliglotyczna architektura rozproszona, w której każdy element robi to, w czym jest najlepszy.
.NET Aspire: dyrygent orkiestry
Zamiast ręcznego konfigurowania połączeń, zmiennych środowiskowych i kontenerów, użyłem .NET Aspire. To on pełni rolę AppHost, który orkiestruje całe środowisko deweloperskie. Dzięki niemu podniesienie bazy wektorowej Postgres, brokera RabbitMQ, magazynu MinIO i dwóch różnych runtime'ów (.NET i Python) sprowadza się do jednego kliknięcia.
Oto jak wygląda definicja topologii w AppHost.cs:
var postgres = builder.AddPostgres("postgres")
.WithImage("pgvector/pgvector", "pg17")
.WithDataVolume()
.AddDatabase("FootballCoachAssistant");
var redis = builder.AddRedis("redis")
.WithImage("redis/redis-stack-server")
.WithRedisInsight();
var rabbitMq = builder.AddRabbitMQ("rabbitmq")
.WithManagementPlugin()
.WithDataVolume();
var minio = builder.AddContainer("minio", "minio/minio")
.WithEndpoint(name: "api", port: 9000)
.WithEndpoint(name: "console", port: 9001)
.WithVolume("minio-data", "/data");
var pythonWorker = builder.AddUvicornApp("python-worker", "../PythonAgent", "main:app")
.WithHttpHealthCheck("/api/health")
.WaitFor(redis).WaitFor(rabbitMq).WaitFor(minio)
.WithReference(postgres).WithReference(redis)
.WithReference(rabbitMq);
var apiService = builder.AddProject<Projects.ApiService>("apiservice")
.WithEndpoint("http", e => e.Port = 5000)
.WaitFor(postgres).WaitFor(redis)
.WaitFor(rabbitMq).WaitFor(minio)
.WaitFor(pythonWorker)
.WithReference(postgres).WithReference(redis)
.WithReference(rabbitMq).WithReference(pythonWorker)
.WithHttpHealthCheck("/health");Backend napisałem w .NET 10, stosując podejście Modular Monolith podzielony na pionowe slice'y (Vertical Slices). Warstwa ta odpowiada za wszystko, co wymaga twardej logiki biznesowej:
Domain: czyste encje (Team, Player, ReportJob).
Application: use-case'y obsługiwane przez MediatR (CQRS) i walidowane przez FluentValidation.
Infrastructure: implementacja EF Core z obsługą wektorów oraz MassTransit do komunikacji z RabbitMQ.
Python: elastyczny AI engine
Podczas gdy .NET pilnuje danych i uprawnień, Python Agent zajmuje się "myśleniem". To tutaj rezyduje LangGraph, który kontroluje kognitywne pętle agenta. Python obsługuje:
generowanie ustrukturyzowanych raportów (JSON),
ekstrakcję wiedzy z plików (OCR / chunking),
zaawansowany RAG i zarządzanie pamięcią semantyczną.
Communication fabric: jak te światy ze sobą rozmawiają?
[ multi-transport ]
To jest punkt, w którym projekt pokazuje swoją siłę. Nie ograniczyłem się do zwykłego REST API. Zastosowałem multi-transport approach:
gRPC: dwukierunkowe strumieniowanie między .NET a Pythonem podczas chatu - niskie opóźnienia i silne typowanie kontraktów (.proto).
RabbitMQ (MassTransit): długotrwałe, asynchroniczne zadania, np. generowanie raportów meczowych.
SignalR: wypychanie statusów z backendu do frontendu w czasie rzeczywistym.
PostgreSQL + pgvector: wspólna baza - .NET zapisuje rekordy, Python przeszukuje wektory.
Dzięki takiemu podziałowi system jest niesamowicie odporny. Nawet jeśli Python Worker jest zajęty ciężkim myśleniem nad raportem, API .NET pozostaje responsywne, a użytkownik widzi postęp na żywo dzięki SignalR.
03 - Event-driven RAG i raporty
Event-driven RAG: ucieczka przed timeoutem
Złota zasada budowania interfejsów mówi: nigdy nie każ użytkownikowi patrzeć na "zawieszonego" spinnera. Problem w tym, że zaawansowane systemy AI łamią tę zasadę z premedytacją. Wygenerowanie głębokiego, wielosekcyjnego raportu taktycznego opartego na wektorowym RAG zajmuje kilkadziesiąt sekund.
Gdybym zamknął to w standardowym, synchronicznym żądaniu HTTP (REST), zabiłbym system na dwa sposoby:
Frontend: przeglądarka rzuciłaby timeout, zanim agent skończyłby myśleć.
Backend (.NET): długo wiszące żądania zablokowałyby pulę wątków (thread-pool starvation).
Rozwiązanie? Rozcięcie komunikacji na pół i wdrożenie architektury event-driven z RabbitMQ.
Pipeline: jak to działa w praktyce?
Zamiast czekać na gotowy raport, proces wygląda jak wrzucenie zadania do asynchronicznej fabryki:
202 Accepted: trener klika „Generate Report”. API w .NET natychmiast zapisuje w PostgreSQL rekord ReportJob (status: Pending), publikuje ReportJobRequested przez MassTransit na szynę i zwraca do Reacta kod HTTP 202 z JobId. Frontend jest wolny.
AI worker (Python): worker ReportJobWorker (aio-pika) konsumuje kolejkę report-job-requested, odpala LangGraph i w trakcie pracy publikuje zdarzenia na report-job-events (np. started, progress, completed).
CQRS i SignalR: .NET konsumuje eventy z Pythona, aktualizuje stan w bazie (np. ApplyReportJobEventCommand) i natychmiast wypycha je przez WebSocket (SignalR) do grupy (np. job:1234).
Inżynieryjne mięso: idempotencja i failover
W systemach rozproszonych nie można zakładać idealnej kolejności ani dokładnie jednej dostawy wiadomości. Co jeśli RabbitMQ dostarczy event progresu 50%, podczas gdy raport w bazie jest już Completed?
W handlerze .NET trzymałem się ścisłej idempotencji i zachowania porządku:
Jeśli przychodzi event dla joba w stanie terminalnym (Completed lub Failed), starsze eventy progresu są ignorowane.
Frontend: na żywo przez SignalR, plus cichy polling co 5 sekund. Zerwany WebSocket w tunelu albo przy przełączeniu Wi‑Fi nie gubi kontekstu ładowania.
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${API_BASE_URL}/hubs/reports`)
.withAutomaticReconnect()
.build();
// Join the room for this report job
await connection.invoke("SubscribeToJob", jobId);
connection.on("report.section.updated", (payload) => {
// Stream sections into the report UI
updateSectionUI(payload.sectionId, payload.content);
});Trade-off, o którym warto wiedzieć
Kolejki i eventy mocno komplikują architekturę: dead-letter queues, kontrakty wiadomości między C# a Pythonem, korelacja logów. W zamian dostajesz system, w którym ciężkie obliczenia AI są odcięte od kruchego budżetu latencji API.
04 - gRPC i Context Bus
gRPC i Context Bus: asystent, który patrzy przez ramię
Większość asystentów AI w aplikacjach to boty zamknięte w osobnej zakładce / czacie - nie widzą ekranu i mają chroniczny brak kontekstu. Chcesz porozmawiać o zawodniku? Musisz napisać: "Powiedz mi, z czym wczoraj miał problem Erling Haaland".
W prawdziwym sztabie, gdy patrzysz z asystentem na profil gracza, pytasz po prostu: "Jak mamy nim zagrać?". Asystent nie dopytuje, o kim mowa, bo dzieli z Tobą kontekst wizualny. Ten wzorzec przeniosłem do aplikacji jako Global Contextual Copilot.
Krok 1: REST ustępuje gRPC i SSE
Żeby czat odpowiadał znak po znaku (token streaming), zwykły REST to za mało. Frontend React jest cienkim klientem, a .NET pełni rolę BFF (Backend-for-Frontend).
Przeglądarka łączy się z .NET przez lekkie Server-Sent Events (SSE). Pod spodem API otwiera wydajny, dwukierunkowy strumień do Pythona po gRPC (HTTP/2).
Kontrakt między serwisami jest w czystym Protobufie:
message ChatStreamRequest {
string thread_id = 1;
string message = 2;
string opponent_team_id = 3;
map<string, string> ui_context = 4; // entityType, entityId, entityName…
}Dzięki gRPC mam silne typowanie i mniejszy narzut serializacji niż przy ciągłym strumieniowaniu dużych JSONów - przy strumieniu tokenów z LLM robi to realną różnicę w latencji.
Krok 2: Context Bus w React
Kluczem jest pole ui_context. Na frontendzie zbudowałem globalny CopilotContextProvider (szynę kontekstu): gdy trener wchodzi np. na profil drużyny, strona bezszelestnie publikuje entityType / entityId. Po wysunięciu panelu czatu i wpisaniu "Jakie mają słabe punkty?" React automatycznie dokleja ten słownik do payloadu wysyłanego do API.
Krok 3: dynamiczne wstrzykiwanie w LangGraph
Prostsze systemy doklejają kontekst do treści wiadomości użytkownika - to zaśmieca logi konwersacji i pali tokeny. Tutaj LangGraph używa ui_context do modyfikacji system promptu zanim poleci wywołanie modelu:
def astream_chat_turn(request: ChatRequest):
system_directives = [
"You are an elite tactical assistant for the coaching staff.",
]
if request.ui_context and "entityName" in request.ui_context:
entity = request.ui_context["entityName"]
system_directives.append(
f"SITUATIONAL AWARENESS: The user is viewing the profile: {entity}. "
f"Resolve pronouns (he, they, them) against this entity."
)
# Run LangGraph with these top-level directives (not pasted into user text)
# …Efekt: asystent w drawerze zmienia kontekst mentalny wraz z nawigacją, bez gubienia wątku - historia rozmowy trzyma się w locie przez Redis. Zwykła aplikacja webowa zaczyna zachowywać się jak system operacyjny napędzany przez AI.
05 - Pamięć kognitywna
Kognitywna pamięć: koniec z amnezją LLM
Klasyczny RAG jest reaktywny: szuka wiedzy głównie z ostatniego pytania. Modele LLM z natury są bezstanowe - każda nowa rozmowa to czysta karta.
W pracy trenera to nie przejdzie. Jeśli w poniedziałek mówisz asystentowi: "Pamiętaj, że zależy mi na agresywnej rotacji skrzydeł u siebie", nie chcesz powtarzać tego w czwartek. System ma to po prostu wiedzieć. Wdrożyłem więc Dual-Memory System - pamięć dwuskładnikową.
1. Pamięć krótkoterminowa (sesja)
Za bieżący wątek odpowiadają checkpointery w LangGraph, spięte z Redisem. Każda wiadomość, stan grafu i wywołania narzędzi są serializowane pod thread_id.
Nawet po restarcie workera Python w połowie generacji agent po podniesieniu wie, na czym skończył.
2. Pamięć długoterminowa (semantyczna)
Tu jest właściwy inżynierski flex: mechanizm uczenia się o trenerze w tle. Tabela coach_preferences_memory w PostgreSQL z pgvector.
Proces ma dwie jasne fazy:
Ekstrakcja (out-of-band): po zakończeniu rozmowy job w tle analizuje logi; lekki model (GPT-4o-mini) wyciąga trwałe preferencje jako atomowe fakty (np. niska linia obrony przeciwko szybkim napastnikom).
Retrieval: na początku nowej rozmowy szybkie similarity search w bazie wektorowej; trafione fakty trafiają do instrukcji systemowych agenta.
Zapis w rozmowie
Później: bez powtórki
Inżynieryjne mięso: wektory w EF Core
Żeby .NET mógł zarządzać tą pamięcią (np. pod administrację), zmapowałem kolumnę wektorową bezpośrednio w Entity Framework Core:
public void Configure(EntityTypeBuilder<CoachPreferenceMemory> builder)
{
builder.ToTable("coach_preferences_memory");
// OpenAI text-embedding-3-small → 1536 dimensions
builder.Property(x => x.Embedding)
.HasColumnType("vector(1536)")
.IsRequired();
builder.HasIndex(x => x.Embedding)
.HasMethod("hnsw")
.HasOperators("vector_cosine_ops");
}Dlaczego to działa lepiej niż zwykły chat?
Asystent buduje profil psychologiczno-taktyczny trenera. Pytanie "Kogo wystawić w obronie?" nie kończy się suchą listą statystyk - model łączy dane z zapisanymi wcześniej preferencjami (np. wysoka linia, szybki powrót obrońcy). To przejście od narzędzia do wyszukiwania danych do partnera w dyskusji.
06 - Generative UI i provenance
Generative UI i provenance: koniec ze ścianą tekstu
Typowy RAG w aplikacji kończy się ścianą Markdownu. Trenerzy i analitycy nie mają czasu na eseje - potrzebują pulpitu i wizualizacji taktycznych.
Wdrożyłem Generative UI (często mówione jako server-driven UI dla AI): zamiast parsować luźny tekst w React, agent w Pythonie - przez structured output w węźle generate w LangGraph - zwraca twardy kontrakt JSON (Schema v2).
Jak AI składa interfejs w locie?
Po „Generate Tactical Plan” backend nie streamuje tylko liter. Sekcje JSON lecą przez SignalR; React mapuje payload na komponenty:
predictedOpponent z formacją (np. 4-2-3-1) → wizualny boisko z „pastylkami” zawodników.
riskFactors → ostrzegawcze kafelki w układzie Bento z ikonami.
Nazwiska w treści jako encje klikalne (clickable entities) - klik otwiera boczny profil gracza.
Provenance: tarcza anty-halucynacyjna
Generative UI to tylko front - wiarygodność jest twardszym problemem. W sztabie nie ma miejsca na zmyśloną kontuzję obrońcy. Dlatego każde ciężkie narzędzie (np. retrieve_opponent_profile) zwraca nie tylko tekst, ale bogaty payload metadanych do audytu.
{
"sections": [
{
"title": "Key threat: Mitoma",
"content": "Brighton will try to isolate Mitoma on the left wing.",
"confidence": 0.92
}
],
"provenance": {
"sourceChunkIds": ["chunk-8f7a-4b21"],
"sourceFileIds": ["file-brighton-scouting-pdf"],
"citations": [
{
"source": "Scout report - Brighton",
"quote": "...often play long to the left to create 1v1s for Mitoma..."
}
]
}
}Na froncie wniosek może mieć klikalny przypis [1]; tooltip pokazuje cytat i wycinek chunka ze skautingowego PDF. Asystent przestaje być czarną skrzynką - każdy fakt ma ślad w bazie.
07 - Data ingestion
Data ingestion: claim-check i karmienie RAG-a
Nawet najlepszy LLM nic nie zdziała bez świeżych danych. W futbolu wiedza płynie jako grube PDF od skautów, CSV z fizyki - sztab musi móc wrzucić plik i od razu iść w analizę.
„Studencki” wzorzec - przyjąć plik po HTTP, zablokować UI, przemielić PDF, wołać embeddingi i zapisać wszystko w jednym żądaniu - przy 50 MB kończy się timeout. Ten sam plik w treści wiadomości RabbitMQ zabiłby brokera. Poszedłem w Claim-Check Pattern (S3 payload pattern).
Asynchroniczny pipeline - jak to się układa
Pod .NET Aspire stoi MinIO (lokalny odpowiednik S3). React wrzuca plik → .NET zapisuje obiekt w buckecie → na kolejkę leci lekki event ingestion-job-requested tylko z ID / kluczem (claim-check) → Python worker pobiera plik bezpośrednio z MinIO, omijając API.
Potem worker przechodzi przez pięć kroków:
Ekstrakcja tekstu (OCR / parsowanie).
Guardrail klasyfikacji domeny - czy plik faktycznie dotyczy futbolu.
Chunking - semantyczne cięcie na fragmenty.
Embeddings - wektory dla RAG.
Zapis do tacticalknowledge w pgvector z metadanymi provenance.
Idempotencja RAG-a: odkurzacz na duplikaty
Klasyczna pułapka: po restarcie lub retry kolejki ten sam PDF dubluje wektory w indeksie - jakość kontekstu i odpowiedzi się psuje. Przed wstawieniem nowych chunków worker czyści poprzednie wpisy po source_file_id (delete_tactical_knowledge_by_source).
def process_ingestion_job(job_payload):
# 1. Pull bytes from object storage using the claim-check key
file_bytes = storage.download(job_payload.storage_key)
# 2. Extract, chunk, embed…
chunks = create_vector_chunks(file_bytes)
# 3. Idempotency: drop previous vectors for this source before insert
db.execute(
"DELETE FROM tacticalknowledge WHERE source_file_id = %s",
(job_payload.file_id,),
)
# 4. Insert with provenance metadata
db.insert_chunks(chunks)Efekt: można przetwarzać setki stron dokumentacji w tle; użytkownik dostaje sygnał przez SignalR, gdy dane są gotowe do pytań asystenta - bez zamrażania API na megabajtach.
08 - Trade-offy i wnioski
Trade-offy i kluczowe wnioski
Architektura to sztuka kompromisu - post-mortem.
Nie ma idealnej architektury. Każda decyzja genialna na diagramie ma w kodzie swoją cenę. Ten ekosystem - .NET, Python, szyny zdarzeń, gRPC, bazy wektorowe - obnażył kilka bolesnych, ale wartościowych lekcji.
1. CQRS (MediatR) - bolesny start, zbawienna struktura
Koszt: wysoki próg wejścia. Na proste zapytania osobne pliki: Command, Handler, Validator - wolniejszy dzień za dzień.
Zysk: gdy doszły asynchroniczne joby RabbitMQ i SignalR, logika nie zamieniła się w spaghetti. Każdy use-case ma sztywną, testowalną granicę (vertical slices).
2. Stan rozproszony: event-driven kontra synchroniczność
Asynchroniczny pipeline uratował UX - UI nie wisiało 40 sekund na generacji. Koszt: piekło operacyjne: ręczna spójność, idempotencja (ignorowanie starych eventów), polityki retry w MassTransit, świadomość eventual consistency.
Wniosek: UI nie może polegać tylko na jednym kanale. Stąd hybryda: SignalR na żywo + cichy polling co 5 s jako fallback.
3. Architektura poliglotyczna (.NET + Python)
Koszt: utrzymanie kontraktów - zmiana JSON raportu lub chat.proto to równoległe PR-y w C# i Pythonie.
Zysk: zasada „najlepsze narzędzie do problemu”. LangGraph w C# to walka z wiatrakami; .NET Aspire jako stabilne API i orkiestrator - dokładnie tam, gdzie powinien być.
Koniec ery „wrapperów”
Projekt był celowo over-engineered - nie po to, by „wdrożyć się u Guardioli”, tylko jako twardy dowód: inżynieria AI to coś więcej niż prompt przez SDK i wyświetlenie go w React.
Współczesne System of Intelligence to pełne systemy rozproszone: strumienie, pamięć długoterminowa, provenance przeciw halucynacjom, odporna komunikacja między procesami. AI nie zwalnia z dobrych praktyk - wręcz przeciwnie, stawia je pod stresem.
