[ case study ]

Man City AI Coach Assistant

Zbudowałem w pełni asynchroniczny, poliglotyczny ekosystem oparty na architekturze sterowanej zdarzeniami (Event-Driven) i strumieniowaniu gRPC. Poznaj historię projektu, w którym świadomy, techniczny over-engineering posłużył mi jako poligon doświadczalny do przetestowania zaawansowanych wzorców AI (kognitywna pamięć, LangGraph) i potężnej infrastruktury .NET Aspire w świecie piłkarskiej analityki.

[ technical showcase ]

System Architect & Full-Stack Engineer

Stack

Plakat case study: AI-native wsparcie decyzji dla elitarnego szkolenia piłkarskiego.

Plakat case study: AI-native wsparcie decyzji dla elitarnego szkolenia piłkarskiego.

Goal

Odrzucenie koncepcji prostego „wrappera na ChatGPT” na rzecz zbudowania proaktywnego, wirtualnego członka sztabu szkoleniowego. Przejście od zwykłego bota tekstowego do systemu klasy Enterprise, który natywnie rozumie kontekst boiska, renderuje przewidywane formacje w locie (Generative UI), asynchronicznie przetwarza raporty skautingowe (Pipeline) i uczy się preferencji trenera dzięki wbudowanej pamięci długoterminowej.

Przewiń dalej

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 ]

Z tym projektem było jednak zupełnie inaczej. Tutaj over-engineering był moim głównym, celowym założeniem. Ten system od dnia zero nie miał rozwiązywać problemów prawdziwych użytkowników. Miał być moim prywatnym, twardym poligonem doświadczalnym - środowiskiem, w którym bez kompromisów mogłem przetestować i połączyć architekturę rozproszoną, strumieniowanie danych, zaawansowane message brokery i kognitywne wzorce AI.

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.

High-level: frontend React, backend .NET 10 z SignalR i MassTransit, Python Agent (FastAPI, LangGraph), infrastruktura pod orkiestracją .NET Aspire.

.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.

Aspire Dashboard - widok grafu: ApiService, Python worker i kontenery infrastruktury w jednej topologii.

Oto jak wygląda definicja topologii w AppHost.cs:

AppHost.cs - topologia środowiska lokalnego
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.

Realtime data flow: HTTP/REST, SignalR, strumień gRPC .NET ↔ Python, kolejka RabbitMQ.

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.

Event-driven report generation: UI → API (202 + JobId) → kolejka → Python (LangGraph RAG) → zdarzenia postępu → .NET consumer → SignalR → UI; przy zerwaniu WebSocket - cichy polling GET co 5 s.

Pipeline: jak to działa w praktyce?

Zamiast czekać na gotowy raport, proces wygląda jak wrzucenie zadania do asynchronicznej fabryki:

  1. 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.

  2. 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).

  3. 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).

UI podczas długiego joba: postęp, kafelki etapów i jawny fallback na polling, gdy WebSocket nie jest wiarygodny (tunel, Wi‑Fi).

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.

ReportHubClient.ts - SignalR + subskrypcja joba
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.

Profil zawodnika i globalny drawer czatu: ten sam widok co trener - asystent wie, na czym oko spoczywa.

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).

Token streaming: SSE (przeglądarka ↔ .NET), potem gRPC bi-di (.NET ↔ Python), ui_context w kontrakcie protobuf.

Kontrakt między serwisami jest w czystym Protobufie:

chat.proto - ChatStreamRequest (fragment)
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:

agent/chat_turn.py - dyrektywy z ui_context
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.

Dwie fazy: (1) po zakończeniu rozmowy - ekstrakcja faktów, embedding, zapis wektorów; (2) nowa rozmowa - wyszukiwanie podobieństwa, wstrzyknięcie do system promptu, agent LangGraph.

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

Ten sam zawodnik w copilocie: najpierw zgłoszenie problemu (łydka), później krótkie pytanie - asystent nadal pamięta kontekst medyczny i taktyczny bez powtarzania całej historii.

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:

CoachPreferenceMemoryConfiguration.cs - pgvector + HNSW
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).

Wygenerowany widok: provenance chunków i plików, formacja przeciwnika na boisku, panel ławki - z JSON-a na natywne komponenty, nie z Markdownu.

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.

response.schema - sekcje + provenance (przykład)
{
  "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).

Frontend intake: wrzutka materiałów skautingowych - ciężka robota idzie w tło, nie w request HTTP.

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:

  1. Ekstrakcja tekstu (OCR / parsowanie).

  2. Guardrail klasyfikacji domeny - czy plik faktycznie dotyczy futbolu.

  3. Chunking - semantyczne cięcie na fragmenty.

  4. Embeddings - wektory dla RAG.

  5. 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).

ingestion_worker.py - idempotentny zapis
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.