Jak działa Kardo, symulator kardiomonitora klasy szpitalnej: architektura, model fizjologii, synteza sygnałów i warstwa bezpieczeństwa dydaktycznego.
Na ekranie monitora migoce migotanie komór, a tuż pod krzywą pulsoksymetr pokazuje SpO₂ 98%. Taki obraz jest fizjologicznie niemożliwy: bez skutecznego wyrzutu serca nie ma fali tętna, którą pulsoksymetr mógłby zmierzyć, a mimo to pojawia się w symulatorach opartych na odtwarzaniu zapętlonych nagrań, w których krzywa i liczba pochodzą z dwóch niezależnych źródeł. Kardo powstało wokół jednej decyzji, która ma temu zapobiec: cała dynamika sygnałów jest liczona z jednego, wspólnego stanu pacjenta, a nie odtwarzana.
To webowy symulator kardiomonitora klasy szpitalnej, wzorowany na monitorach Philips IntelliVue i GE Carescape. Renderuje w czasie rzeczywistym osiemnaście kanałów sygnału: dwanaście odprowadzeń EKG oraz pletyzmogram, inwazyjne ciśnienie tętnicze, ośrodkowe ciśnienie żylne, ciśnienie w tętnicy płucnej, oddech impedancyjny i kapnografię. Obsługuje 26 rytmów serca o odróżnialnej morfologii, farmakodynamikę leków, defibrylator zgodny z wytycznymi ERC, cztery tryby pracy dydaktycznej oraz opcjonalną, lokalną warstwę AI, z zerową tolerancją na błąd w przebiegach.
Kolejność rozdziałów wynika z jednej potrzeby: oddzielenia modelu fizjologii od jego prezentacji. Najpierw architektura i przepływ danych (rozdz. 2), potem osobno źródło wartości liczbowych (rozdz. 3) i osobno synteza przebiegów (rozdz. 4), następnie warstwy kliniczne (rozdz. 5–11), organizacja pracy i uprawnień (rozdz. 12), warstwa AI i dane (rozdz. 13–16), a na końcu uczciwy rachunek ograniczeń (rozdz. 17).
Kod jest zorganizowany jako monorepo (npm workspaces) z wyraźnym rozdziałem silnika od interfejsu i backendu.
| Pakiet / aplikacja | Odpowiedzialność | Stos |
|---|---|---|
packages/core | Silnik: synteza sygnałów, scheduler zdarzeń, alarmy, farmakodynamika, detektory, trendy. TypeScript bez zależności od DOM. | TypeScript |
packages/models | Typy domenowe (PatientState, SimEvent, Alarm, Rhythm) i macierz uprawnień (can(), MATRIX). | TypeScript |
packages/audio | Tony alarmów i defibrylatora wg IEC 60601-1-8. | Web Audio API |
packages/scenarios | Biblioteka gotowych przypadków klinicznych. | TypeScript |
apps/web | Aplikacja kliencka: React SPA, renderowanie Canvas 2D, panele kliniczne, tryby. | React 18, Vite, Tailwind |
apps/api | Backend: REST, WebSocket, baza, warstwa AI. | Fastify, PostgreSQL, Drizzle ORM, pgvector |
Rozdział core/models od apps/web jest celowy: silnik nie zna DOM-u ani Reacta, więc daje się testować w izolacji i uruchamiać w Web Workerze. To pozwala trzymać całą fizjologię z dala od wątku interfejsu.
Cała symulacja działa w Web Workerze, osobnym od wątku UI (sim-worker.ts). Worker liczy próbki wszystkich kanałów ze stałą częstotliwością i wysyła je do interfejsu partiami; UI tylko rysuje. Timing opiera się na performance.now() i pętli setTimeout o kroku ~4 ms.
SAMPLE_RATE_HZ = 250: częstotliwość próbkowania sygnałów; SAMPLE_INTERVAL_MS = 4.BATCH_INTERVAL_MS = 10: co 10 ms (100 Hz) worker wysyła partię próbek (Float32Array per kanał). Przy 60 Hz odświeżania daje to 2–3 próbki na klatkę, strumień gęstszy niż tempo renderowania, bez luk.SNAPSHOT_INTERVAL_MS = 1000: co sekundę (1 Hz) worker wysyła pełny PatientState z wartościami liczbowymi, zgodnie z konwencją Philips IntelliVue / GE Carescape.heart_beat przy każdym skurczu komorowym (do tonu QRS i migotania diody).Interfejs (SimProvider.tsx, WaveformChannel.tsx) odbiera partie do buforów pierścieniowych i rysuje przebieg na Canvas 2D w pętli requestAnimationFrame, w trybie przesuwu, jak realny monitor. Dzięki wyniesieniu obliczeń do workera interfejs utrzymuje 60 klatek na sekundę nawet przy kilkunastu sprzężonych przebiegach.
Worker generuje zawsze pełny komplet 18 kanałów: dwanaście odprowadzeń EKG (I, II, III, aVR, aVL, aVF, V1–V6) oraz sześć kanałów niekardiograficznych: PLETH, ABP, CVP, PAP, RESP, CO2.
Role są ortogonalne do trybu pracy (rozdz. 12) i decydują o kierunku przepływu sterowania.
Synchronizacja działa przez wspólny interfejs SyncTransport z dwoma backendami (sync-transport.ts):
cardiomon-sync: domyślny, bezkonfiguracyjny, dla okien w obrębie jednej maszyny/pochodzenia.VITE_WS_URL; łączy przez serwer (endpoint GET /ws/sync?role=&room=), co pozwala rozdzielić Controller i Display na różne maszyny w LAN. Serwer prowadzi pokoje, robi fan-out komend według ról i cache'uje ostatni stan Controllera (warm-join).Controller rozgłasza pełny stan (__sync_state__) co 2,5 sekundy (HEARTBEAT_MS = 2500), a świeżo otwarty Display prosi o stan komendą __sync_request__.
Istotny mechanizm bezpieczeństwa: komendy instruktora relayowane przez serwer są autorytatywnie stemplowane znacznikiem _origin: 'instructor', dzięki czemu worker studenta waliduje je jako uprawnienia instruktora, a nie studenta (rozdz. 12).
Wyświetlane parametry nie są przechowywane obok przebiegów. Są z nich wyprowadzane co takt symulacji przez silnik fizjologii (physiology-engine.ts). To realizacja zasady z rozdziału 1. Poniższe wzory pokazują, jak z demografii pacjenta i bieżącej hemodynamiki powstają liczby na monitorze.
Objętość wyrzutowa liczona jest z masy ciała, ze współczynnikiem zależnym od grupy wiekowej i korektą na skrócone napełnianie przy tachykardii:
Rzut serca sprzęgnięty jest z ciśnieniem, bo w hipotensji i wstrząsie wyrzut spada, a nie żyje własnym życiem z samego tętna:
Powierzchnia ciała liczona jest wzorem DuBois:
Ten szczegół ma znaczenie kliniczne. Stałe dorosłego (SV 70 mL, BSA 1,7 m²) dawały noworodkowi rzut serca ~9,8 L/min zamiast ~0,7, błąd rzędu wielkości. Wyprowadzenie z demografii usuwa go u źródła.
Ciśnienie średnie liczone jest klasycznie:
Wskaźnik perfuzji (PI) jest funkcją utlenowania, ciśnienia i tętna, z bazową wartością 2,5 przy SpO₂ 98% i MAP 90 mmHg, dochodzącą do celu ze stałą czasową ~8 s:
Silnik utrzymuje kilka pętli sprzężenia zwrotnego, które sprawiają, że zmiana jednego parametru pociąga inne:
plethQuality) jest zawsze przeliczana z bieżącego PI, co bramkuje alarm słabej perfuzji.Każdy przebieg jest liczony z modelu matematycznego, próbka po próbce, a nie odtwarzany. Wspólną podstawą jest kilka funkcji pomocniczych (utils/math.ts): funkcja Gaussa gaussian(x,μ,σ) = exp(−(x−μ)²/2σ²), deterministyczny generator liczb pseudolosowych mulberry32 (do odtwarzalnych artefaktów i jitterów) oraz szum gaussowski metodą Boxa–Mullera. Determinizm PRNG jest ważny: te same warunki dają ten sam przebieg, co jest warunkiem testowalności.
Sygnał EKG powstaje jako suma pięciu funkcji Gaussa odwzorowujących załamki P, Q, R, S i T w każdym cyklu (synth/ecg.ts):
Parametry bazowe skalibrowano dla odprowadzenia II i rytmu zatokowego przy HR 60 (BASE_PQRST), z jawnym uzasadnieniem klinicznym każdej wartości w komentarzach kodu:
| Załamek | Amplituda | Pozycja μ (ms od R) | Szerokość σ (ms) |
|---|---|---|---|
| P | 0,20 mV | −175 | 32 |
| Q | −0,10 mV | −25 | 6 |
| R | 1,30 mV | 0 | 8 |
| S | −0,25 mV | 22 | 7 |
| T | 0,55 mV | 190 | 38 |
Z tych parametrów wynikają odstępy w granicach normy: QRS ≈ 80–90 ms, QT ≈ 330 ms, PR ≈ 150–200 ms.
Morfologia dwunastu odprowadzeń różnicowana jest macierzą wzmocnień per odprowadzenie (LEAD_GAIN), osobno dla P, QRS i T. Odprowadzenie aVR ma współczynniki ujemne (QRS −1,0) , stąd jego odwrócony zespół. Na to nakładają się: przesunięcie odcinka ST per odprowadzenie (stShiftMv, podstawa detekcji STEMI), warianty załamka T (prawidłowy, nadostry, odwrócony, dwufazowy, płaski, spiczasty) oraz sprzężenie zatokowej arytmii oddechowej z rzeczywistym torem oddechu.
Pletyzmogram (synth/pleth.ts) ma kształt zbliżony do fali tętniczej, ale z opóźnieniem ~200 ms względem EKG (PLETH_DELAY_MS = 200), modulacją oddechową (PVI) i amplitudą proporcjonalną do wskaźnika perfuzji. Przy braku perfuzji przebieg spłaszcza się do samego szumu; przy niskim PI (< 0,5) amplituda wyraźnie maleje.
Krzywa ABP (synth/abp.ts) odwzorowuje falę w tętnicy promieniowej: czas przejścia ~150 ms od załamka R, strome ramię skurczowe (20–40 ms), wcięcie dykrotyczne przy zamknięciu zastawki aortalnej (~250 ms po szczycie) i powolny spadek rozkurczowy ze stałą czasową ~300 ms. Cały cykl trwa jeden odstęp R–R. W migotaniu przedsionków amplitudy są zmienne uderzenie do uderzenia (krótszy R–R = mniejsze napełnianie = niższe skurczowe). Modelowane są też artefakty linii tętniczej: szybkie płukanie i tłumienie.
cvp.ts): fale a/c/v z zejściami x i y, amplituda 1–3 mmHg, modulacja oddechowa; w migotaniu przedsionków zanika fala a, w niedomykalności trójdzielnej rośnie fala v.pap.ts): czas przejścia ~100 ms, wartości sys 15–30 / dia 4–12 mmHg, mniej wyraźne wcięcie dykrotyczne niż w ABP.resp.ts): sinus o częstości RR/60 Hz z drobnymi oscylacjami kardiogennymi; w bezdechu przebieg płaski.capno.ts): czterofazowy kształt (linia podstawowa, ramię wznoszące, plateau, faza wdechowa) z dziesięcioma wariantami: prawidłowy, „płetwa rekina" (obturacja), „szczelina kurary" (resztkowa miorelaksacja), rosnąca linia podstawowa, oscylacje sercowe, wydłużone plateau, hipo- i hiperwentylacja, intubacja przełyku (malejące amplitudy do zera) oraz płaski (bezdech/rozłączenie).System modeluje 26 rytmów, z których każdy ma odróżnialną morfologię, nie jest skalowanym rytmem zatokowym. Pełna lista pochodzi z typu Rhythm (packages/models/src/index.ts):
| Grupa | Rytmy |
|---|---|
| Zatokowe | SR (zatokowy), SB (bradykardia), ST (tachykardia), SA (arytmia oddechowa) |
| Nadkomorowe | AF (migotanie przedsionków), AFL (trzepotanie), AT (częstoskurcz przedsionkowy), AVNRT (nawrotny węzłowy), WPW (preekscytacja, fala delta), JR (rytm węzłowy) |
| Komorowe | VT (jednokształtny), VT_POLY (wielokształtny), TDP (torsades de pointes), VF (migotanie komór), VFL (trzepotanie komór) |
| Zastępcze / zatrzymanie | IVR (idiowentrykularny), AIVR (przyspieszony), ASY (asystolia), PEA (czynność elektryczna bez tętna) |
| Bloki AV | AVB_I (I°), AVB_II_M1 (Mobitz I / Wenckebach), AVB_II_M2 (Mobitz II), AVB_III (całkowity) |
| Stymulacja | PACED_A (przedsionkowa), PACED_V (komorowa), PACED_AV (sekwencyjna) |
Odróżnialność morfologiczna jest wymogiem, nie ozdobą. Trzepotanie przedsionków ma falę piłokształtną z blokiem przewodzenia; migotanie komór zanika amplitudą od grubo- do drobnofalistego; torsades skręca oś wokół linii izoelektrycznej; blok Wenckebacha pokazuje stałe odstępy P–P przy skracającym się R–R aż do pauzy. Poprawność tych morfologii jest utrwalona testami regresyjnymi (rozdz. 15).
Najważniejsza pojedyncza decyzja projektowa dotyczy rozdzielenia rytmu elektrycznego od tętna. W realnym pacjencie rytm na EKG nie gwarantuje skutecznego wyrzutu , stąd rozkojarzenie elektromechaniczne w PEA czy brak perfuzji w migotaniu komór. Symulator odwzorowuje to jako twardy inwariant, z jednym źródłem prawdy (utils/perfusion.ts).
Funkcja isPulseless(rhythm, pulseless) jest pytana bezpośrednio przez wszystkie elementy zależne od perfuzji: syntezatory pletyzmogramu, ABP, PAP i CVP (płaski przebieg), warstwę numeryczną (SpO₂ i ciśnienie nieważne, --), model zatrzymania krążenia i logikę defibrylatora. Reguła obowiązuje w obie strony i nie ma wyjątku „płaski przebieg z żywą liczbą".
Dlaczego jedno źródło prawdy? Bo rozjazd „przebieg płaski, ale liczba idzie normalnie" brał się dokładnie z rozproszenia tej decyzji: przebiegi bramkowały się nazwą rytmu, a numeryka zależała od PI/MAP zerowanych tylko na jednej ze ścieżek zmiany rytmu (inne, jak setParameter, ją omijały). Trzymając zbiór w jednym miejscu i pytając o rytm wprost, gwarantuję spójność niezależnie od tego, jak rytm został ustawiony.
Zatrzymanie krążenia nie jest pojedynczym stanem, lecz procesem z oknem odwracalności (data/arrest-model.ts, ARREST_MODEL). Model rozróżnia okna „no-flow" zależne od mechanizmu:
z progami nieodwracalności (terminalMs) oraz degeneracją migotania komór w asystolię (vfToAsystoleMs ~12 min). Przepływ podczas RKO ma trzy poziomy jakości (dobry 0,3 / normalny 0,5 / słaby 0,8), a skuteczność defibrylacji maleje ze stałą czasową (defibDecayTauMin ~8 min bez skutecznej resuscytacji). Hipotermia wydłuża okno odwracalności, zgodnie z zasadą „nie jest martwy, dopóki nie jest ciepły i martwy".
Podczas braku perfuzji EtCO₂ spada o ~1 mmHg/s do zera (brak transportu CO₂ do płuc). Źródłem klinicznym modelu jest notatka 05-Scenariusze/Downtime-i-nieodwracalnosc.md oparta na wytycznych ERC 2021 / AHA.
Leki działają deterministycznie, bez udziału modelu językowego (pharma/drug-pd.ts). Odpowiedź na dawkę liczona jest z krzywej Hilla:
Parametry emax, ec50 i współczynnik Hilla h pochodzą z danych leku (drugs.pd), a maksymalna zmiana parametru jest ograniczana (DELTA_CAP per cel). Determinizm oznacza, że efekt leku jest w pełni odtwarzalny i niezależny od dostępności AI.
Niezależnie od tego, co zwróci warstwa AI, trzynaście deterministycznych reguł egzekwuje znane niebezpieczne interakcje (contraindications.ts). Każda reguła dopisuje zdarzenia lub wymusza zmianę rytmu, z oceną istotności (krytyczna / ciężka / umiarkowana).
| # | Warunek | Skutek |
|---|---|---|
| 1 | β-bloker + astma/POChP (J45/J44) | bronchospazm (RR↑, SpO₂↓, kapnogram „płetwa rekina") |
| 2 | Lidokaina + blok AV III° lub Mobitz II | asystolia |
| 3 | Werapamil/diltiazem + β-bloker (przewlekle) | ciężka bradykardia → blok AV |
| 4 | Adenozyna + WPW z AF/AFL | migotanie komór (przyspieszenie drogą dodatkową) |
| 5 | Opioid + POChP | depresja oddechowa (retencja CO₂) |
| 6 | Opioid + sedatyw (BZD) | synergia depresji OUN |
| 7 | KCl i.v. bolus > 20 mEq | asystolia (hiperkaliemia) |
| 8 | β-bloker + blok AV II M2 / III° | pogłębienie bloku → asystolia |
| 9 | Flekainid/propafenon + strukturalna choroba serca | proarytmia komorowa VT (badanie CAST) |
| 10 | Werapamil i.v. + niewydolność serca | wstrząs kardiogenny |
| 11 | Adenozyna + astma/POChP | bronchospazm |
| 12 | Sukcynylocholina + hiperkaliemia (K⁺ ≥ 5,5) | VT/VF |
| 13 | NLPZ/aspiryna + niewydolność nerek | nefrotoksyczność + ryzyko krwawienia (ostrzeżenie) |
Reguły są czystymi funkcjami, testowalnymi, bez efektów ubocznych, operującymi na kodach ICD-10 chorób pacjenta, jego lekach przewlekłych i bieżącym rytmie.
Logika defibrylatora opiera się na wytycznych ERC 2021 / AHA 2020 dla defibrylacji dwufazowej (engine/defibrillator.ts). Decyzja o synchronizacji zależy od osi tętna, nie od samej częstości:
Model zawiera bezpiecznik: włączenie synchronizacji na rytmie bez wyczuwalnego tętna (np. sync na VF) przerywa wyładowanie ze statusem „aborted" i instrukcją wyłączenia sync. Skuteczność narasta z energią: prawdopodobieństwo konwersji rośnie od ~30% przy 100 J do ~85% przy 250 J, przemnożone przez czynnik zaniku (downtime). Modelowane są też realne komplikacje: ~15% szans na ogłuszenie mięśnia i przejściową asystolię po skutecznym wyładowaniu, a przy powrocie krążenia tętno post-ROSC w zakresie 90–120/min.
System obsługuje pomiar nieinwazyjnego ciśnienia (NIBP z konfigurowalnym interwałem), linię tętniczą z szybkim płukaniem, resuscytację krążeniowo-oddechową, intubację, monitorowanie zwiotczenia (TOF), głębokości znieczulenia (BIS), ciśnienia śródczaszkowego i perfuzji mózgowej (ICP/CPP) oraz cewnik Swana-Ganza. To zestaw parametrów właściwy dla stanowiska anestezjologicznego i intensywnej terapii, nie tylko kardiologicznego.
Silnik alarmów realizuje normę IEC 60601-1-8 (engine/alarm-engine.ts). Każdy alarm ma priorytet (HIGH / MEDIUM / LOW / INFO), warunek wyzwalający i opóźnienie odfiltrowujące drgania (debounce), a tony audio generuje packages/audio zgodnie z melodycznymi wzorcami normy.
Przykładowa hierarchia priorytetów: skrajna tachy-/bradykardia, arytmie złośliwe, skrajnie niskie SpO₂ i skrajnie niskie MAP to alarmy HIGH; wartości poza progami roboczymi (RR, EtCO₂, umiarkowana hipotensja) to alarmy MEDIUM.
Dwa mechanizmy bramkowania zapobiegają fałszywym alarmom z artefaktów:
poor-pleth) bramkuje alarm SpO₂,damped ART) bramkuje alarm MAP.Osobną rodziną są wzorce STEMI wykrywane z mapy przesunięć ST wg czwartej uniwersalnej definicji zawału: uniesienie ≥ 0,1 mV (1 mm) w co najmniej dwóch sąsiednich odprowadzeniach jednego terytorium. Rozróżniane są ściana dolna (II, III, aVF), przednia (V1–V4), boczna (I, aVL, V5, V6) oraz tylna (depresja ST V1–V3 jako ekwiwalent STEMI wymagający pilnej koronarografii).
System rozróżnia dwa mechanizmy zdarzeń, które łatwo pomylić.
Trzynaście profili odpalanych ręcznie z katalogu (data/clinical-events.ts), w trzech kategoriach:
Każdy profil to tabela etapów (stages) nieleczonej progresji: dla danej sekundy atSec zestaw docelowych wartości parametrów osiąganych rampą. Kluczowe jest to, że interpretuje je jeden, generyczny mechanizm (applyClinicalProfile): dodanie zdarzenia to wpis w tablicy danych, bez zmian w kodzie silnika i bez rozgałęzień per rozpoznanie.
Drugi mechanizm to losowe zdarzenia wynikające z profilu chorobowego pacjenta, opisanego w bazie kolumną physiologyImpact (JSONB na tabeli diseases). Ładunek zawiera offsety parametrów jako zakresy [min, max], docelowy rytm, przesunięcia ST oraz listę zdarzeń chorobowych z prawdopodobieństwem na minutę. Harmonogram (disease-event-scheduler.ts) co ~5 s przelicza prawdopodobieństwo z modyfikatorami: leki ochronne (β-bloker w STEMI zmniejsza ryzyko VT), wielochorobowość (niewydolność serca + arytmia zwiększa ryzyko) i bieżące parametry (hipotensja sprzyja desaturacji).
Prawdopodobieństwo na minutę przeliczane jest na takt wzorem pTick = 1 − (1−pMin)^dtMin, a generator jest deterministyczny i seedowany, więc cała dynamika stochastyczna działa bez AI.
W trybach scenario, story i exam zdarzenia patologiczne przechodzą przez bramkę plauzybilności (engine/event-plausibility.ts), której punktem kontrolnym jest obsługa add_event w workerze. Bramce podlegają tylko zdarzenia wnoszące patologię (zmiana rytmu, zmiana ST, odma, anafilaksja, krwawienie, zaciśnięcie chirurgiczne, wymuszona ekstubacja, nacięcie); leki, akcje kliniczne, powrót do rytmu zatokowego i normalizacja ST przechodzą zawsze.
Reguła: zdarzenie patologiczne jest dozwolone, gdy ma pokrycie w profilu chorobowym pacjenta (isEventGrounded) lub niesie werdykt walidatora RAG różny od block. W przeciwnym razie zostaje odrzucone, i to nie po cichu: worker emituje event_rejected, a interfejs pokazuje powiadomienie. Tylko tryb sandbox jest w pełni wolny; story również bramkuje per pacjent.
| Tryb | Rola dydaktyczna | Zasada |
|---|---|---|
| Sandbox | test / instruktor | Wszystko wolno: dowolne wstrzyknięcia bez podłoża fizjologicznego. |
| Scenario | gra solo | Pełna karta pacjenta, tylko biologicznie możliwe zdarzenia, panel leków, postęp zapisywany w bazie. |
| Story | prowadzenie pacjenta | Dynamika wyłącznie ze zdarzeń chorobowych, czas rzeczywisty. |
| Exam | egzamin z nadzorem | Student z zablokowanym interfejsem i timerem; instruktor planuje zdarzenia i ocenia zdalnie. |
Uprawnienia wynikają z macierzy Rola × Tryb → zbiór zdolności (capabilities.ts, funkcja can()), z domyślną odmową: co nie jest w zbiorze, jest zabronione. Macierz jest egzekwowana na trzech warstwach, żeby żadna pojedyncza nie była jedynym zabezpieczeniem:
<Gate cap="..."> renderuje element tylko, gdy zdolność jest przyznana.useCapability(cap) w warstwie stanu.isMessageAllowed(msg, role, mode) waliduje każdą komendę przed wykonaniem; odrzucenie zwraca command_rejected.Zbiór zdolności (Capability, 28 pozycji) dzieli się na grupy: inject.* (niefizjologiczne wstrzyknięcia: tylko sandbox lub override instruktora), clinical.* (interwencje: lek, defibrylacja, NIBP, FiO₂, płukanie, RKO, intubacja), scenario.*, config.*, time.* oraz instructor.*.
Kilka reguł wartych podkreślenia, bo niosą sens dydaktyczny:
clinical.*, wszystkie panele wstrzyknięć, konfiguracji i scenariusza są ukryte.time.pause zablokowane).time.seek (przewijanie) jest dostępne tylko w sandbox.inject.* i instructor.*, ale nie clinical.*: interweniuje przez sterowanie sesją, nie zastępuje studenta przy łóżku.sync_state jest dozwolona wyłącznie dla roli Display, co blokuje prymitywną eskalację uprawnień.Egzamin ma checklistę oceniającą (ExamChecklistItem): wzorce dopasowania działań, okna czasowe i punktacja, plus dziennik akcji do debriefingu.
Warstwa AI jest w całości opcjonalna i stanowi nadbudowę, a nie fundament. Deterministyczny rdzeń, czyli farmakodynamika (krzywa Hilla), trzynaście reguł kontraindykacji, harmonogram zdarzeń chorobowych na seedowanym generatorze i inwariant perfuzji, działa bez jednego wywołania modelu językowego. AI dokłada dwie rzeczy: semantyczne wyszukiwanie w bazie wiedzy klinicznej (RAG) oraz kilka generatywnych funkcji pomocniczych. Co ważne dla architektury: system rozdziela dwa niezależne połączenia AI, osobno model czatu (LLM) i osobno model embeddingów, każde z własnym adresem, kluczem i możliwością pracy lokalnej. Wszystkie fakty w tym rozdziale pochodzą z apps/api/src/services/{ai,knowledge,settings}.ts, routes/{ai,rag}.ts, db/schema/knowledge.ts oraz packages/core/src/engine/event-plausibility.ts.
Źródłem wiedzy jest vault Obsidian, ingestowany skryptem (scripts/ingest-vault.ts). Skrypt wciąga wyłącznie sześć folderów klinicznego ground-truth: 02-Parametry, 03-Arytmie-i-EKG, 04-Alarmy, 05-Scenariusze, 09-Algorytmy-syntezy i 99-Referencje. Reszta vaulta (plany, architektura, UI) zostaje dokumentacją tylko w Obsidianie, zgodnie z zasadą „jedna treść, jedno źródło". Przed chunkingiem usuwany jest frontmatter (stripFrontmatter), a pliki krótsze niż 50 znaków są pomijane. Skrypt ma tryby --dry-run (sama lista plików) i --reset (czyści knowledge_chunks i knowledge_sources, po czym ingestuje od zera).
Chunker (chunkText w services/knowledge.ts) jest świadomy granic zdań, a jego parametry są jawne:
~2000 znaków (około 500 tokenów), z zakładką (overlap) ~200 znaków (około 50 tokenów),[.!?] + spacja + wielka litera), by nie rozrywać zdań w połowie,md5(content) względem istniejących fragmentów: powtórki nie są ponownie embedowane,100 fragmentów na wywołanie (EMBED_BATCH_SIZE), taniej i szybciej.Metadane źródła (tytuł, URL, typ licencji, znaczniki) trafiają do knowledge_sources, a każdy fragment do knowledge_chunks z tagami ['vault', folder] i krótkim streszczeniem (extractSummary: nagłówek, pierwsze zdanie lub pierwsze 80 znaków). Usunięcie źródła kaskadowo usuwa jego fragmenty.
Domyślnym modelem embeddingów jest bge-m3, 1024 wymiary, uruchamiany lokalnie przez LM Studio (resolveEmbeddingModel zwraca EMBEDDING_MODEL ?? 'bge-m3'). bge-m3 jest wielojęzyczny i dobrze obsługuje polski, co ma znaczenie, bo baza wiedzy jest po polsku. Embeddingi liczone są przez interfejs zgodny z OpenAI (POST /embeddings), więc ten sam kod działa z LM Studio lokalnie i z OpenAI w chmurze; różni je tylko adres bazowy.
0007_embedding_1024.sql (1536 → 1024).Magazynem jest rozszerzenie pgvector w PostgreSQL. Fragmenty wiedzy przechowują kolumnę embedding vector(1024) (NOT NULL), a nad nią zbudowany jest indeks przybliżonego wyszukiwania:
pgvector jest jedynym backendem RAG. Wcześniejszy komponent LightRAG (retrieval i generacja w jednym sidecarze) został porzucony 16 lipca 2026 jako kruchy, a cały jego kod, endpointy, UI i wpis w docker-compose usunięto. Ślad po nim to jedynie ignorowany parametr _mode w sygnaturze searchKnowledge, zachowany dla zgodności wywołań (dawne tryby mix/naive/local/global/hybrid już nie istnieją).
Wyszukiwanie (searchKnowledge(query, topK = 5)) embeduje zapytanie tym samym modelem, po czym pyta pgvector operatorem odległości kosinusowej <=> i sortuje rosnąco po odległości:
Podobieństwo (similarity) mieści się w zakresie 0..1 (1 = identyczne). Próg odcięcia nie jest globalny, lecz dobierany per funkcja, bo różne zastosowania mają różną tolerancję na słaby dopasowanie:
≤ 0,50,> 0,45,Nie ma ponownego rankowania (rerank) ani wyszukiwania hybrydowego słowo plus wektor. Gdy żaden fragment nie przekroczy progu, kontekst RAG jest po prostu pomijany, a model odpowiada bez cytatów z bazy. To świadoma, ale istotna granica: retrieval jest prosty i zależny od jakości embeddingów zapytania.
Model językowy podłączany jest przez interfejs zgodny z OpenAI (POST /chat/completions). Provider wynika wprost z adresu bazowego, więc ta sama warstwa obsługuje chmurę (OpenRouter) i tryb lokalny (LM Studio) bez zmian w kodzie. Adresy i klucze rozstrzygane są kaskadowo (services/settings.ts):
| Warstwa | Kolejność adresu bazowego | Domyślny provider | Klucz |
|---|---|---|---|
| Czat (LLM) | DB → LLM_BASE_URL → OpenRouter | openrouter.ai/api/v1 | OPENROUTER (opcjonalny dla local) |
| Embeddingi | DB → EMBEDDING_BASE_URL → OpenAI | api.openai.com/v1 | OPENAI (opcjonalny dla local) |
Rozpoznanie endpointu lokalnego jest regexowe (isLocalUrl: localhost, 127.0.0.1, host.docker.internal, sieci prywatne 192.168.x, 10.x, 172.16-31.x). Dla adresu lokalnego klucz nie jest wymagany, koszt wynosi zero, a dane nie opuszczają maszyny, co przy treściach klinicznych ma realne znaczenie. Domyślny model czatu to anthropic/claude-3.5-haiku; lokalnie sprawdzane są modele takie jak Bielik-11B czy Gemma, w chmurze rodzina Claude (Haiku/Sonnet/Opus) i Gemini.
Warstwa uwzględnia praktyczne różnice między providerami:
240 s dla endpointu lokalnego (wolny model 11B), 120 s dla chmury, egzekwowany przez AbortController z czytelnym komunikatem po timeoucie,response_format: json_object (zwraca 400), więc dla local jest ono pomijane; JSON wymuszają wtedy same prompty, a parsery mają awaryjne domykanie nawiasów,haiku → sonnet-4.6 (tylko na OpenRouter) dla zadań o ciężkim, strukturalnym JSON: farmakodynamika, ekstraktor chorób, kreator pacjenta,Warstwa udostępnia osiem odrębnych zastosowań. Cztery pierwsze to funkcje pomocnicze przy łóżku lub w edytorze, cztery kolejne to narzędzia autorskie i oceniające. Kolumna „RAG" mówi, czy i jak funkcja dociąga kontekst z bazy wiedzy.
| Funkcja | Wywołanie w kodzie | RAG (top-K, próg) | Zabezpieczenie wyjścia |
|---|---|---|---|
| Doradca lekowy („Porada AI") | drug_advisor | top-5, > 0,50 | tylko leki z katalogu (whitelist kodów), max 6 sugestii |
| Farmakodynamika leku | drug_effect | top-3, > 0,45 | clamp delt (HR ±80, MAP ±60, SpO₂ −20..+5, ...) |
| Wpływ choroby na parametry | disease_impact | top-3, > 0,45 | clip offsetów do zakresów fizjologicznych |
| Generator scenariuszy | scenario_builder | brak | parser z domykaniem nawiasów, 1 retry |
| Ekstraktor chorób z tekstu | disease_extractor | brak | walidacja enum rytmu, drugi przebieg guidance |
| Kreator pacjenta z narracji | patient_creator | brak | naprawa obciętego JSON (stos nawiasów) |
| Walidator zdarzeń | validate-event | top-10, bez progu | werdykt ok/warning/block, cache 24 h |
| Ocena sesji (debrief) | grade-session | top-15, bez progu | ekstrakcja JSON, cache 24 h |
Doradca lekowy (Porada AI) to najbardziej widoczna funkcja: przycisk „Porada AI" na monitorze otwiera modal (DrugAdvisorModal.tsx), który wysyła do /api/ai/drug-advisor migawkę stanu pacjenta (vitals, rytm, choroby ICD-10, leki stałe, alergie, aktywne alarmy) wraz z pełnym katalogiem leków z bazy. Model zwraca ocenę sytuacji i uszeregowane sugestie (first-line / second-line / consider / avoid) z dawką, drogą i uzasadnieniem, plus działania niefarmakologiczne i nazwę algorytmu (ACLS/ALS/ATLS). Kluczowy bezpiecznik: clipDrugAdvisor odrzuca każdą sugestię, której drugCode nie występuje w katalogu, więc model nie może „wymyślić" leku spoza formularza. Dodatkowo per-choroba przekazywany jest hint z bazy (treatmentGuidance: sugerowane kategorie i leki przeciwwskazane).
Generator scenariuszy działa jako czat wielokrotny w zakładce AI (AITab.tsx): model najpierw dopytuje o brakujące informacje (typ przypadku, pacjent, przebieg, cele), a gdy ma ich dość, zwraca gotowy scenariusz (stan wyjściowy plus oś czasu zdarzeń), który uczeń może uruchomić w silniku. Funkcje autorskie (ekstraktor chorób, kreator pacjenta) generują wpisy do bazy z pełną walidacją enum rytmów, kapnogramów i ścieżek parametrów, więc niepoprawne wartości od modelu są odrzucane, a nie zapisywane.
Granica „biologicznie możliwe / niemożliwe" jest egzekwowana dwuwarstwowo, z prymatem warstwy deterministycznej. Punktem kontrolnym jest obsługa add_event w workerze; przechodzą przez nią tylko zdarzenia wnoszące patologię (zmiana rytmu, zmiana ST, odma, anafilaksja, krwawienie, zaciśnięcie, wymuszona ekstubacja, nacięcie), a leki i akcje kliniczne są zwolnione.
isEventGrounded): zdarzenie patologiczne jest dozwolone, gdy ma pokrycie w profilu chorobowym pacjenta. Dla zmiany rytmu oznacza to, że któraś z chorób pacjenta ma w swoich zdarzeniach ten właśnie rytm; powrót do rytmu zatokowego i normalizacja ST przechodzą zawsze.POST /api/rag/validate-event): retrieval z pgvector (top-10) plus wywołanie LLM, który zwraca werdykt ok / warning / block i cytowania źródeł, parsowane ze sztywnego formatu VERDICT/REASON. Wynik jest cache'owany 24 h w pamięci.Bramka trybowa (isEventAllowedInMode) łączy obie warstwy: werdykt block odrzuca zdarzenie, werdykt ok/warning je dopuszcza, a przy braku werdyktu decyduje uziemienie deterministyczne. W trybach scenario i story to właśnie uziemienie jest podstawową gwarancją; werdykt RAG jest opcjonalną nadbudową i w praktyce dołączany jest głównie na ścieżce override instruktora (InstructorControls.tsx), gdzie na zdarzeniu stemplowane jest pole validated tylko dla werdyktów ok/warning.
warning z komunikatem „akcja dozwolona z ostrzeżeniem", a nie block. Nie blokuje więc prowadzącego przy problemie z infrastrukturą, ale też nie jest twardym filtrem bezpieczeństwa. Twardą regułą pozostaje deterministyczne uziemienie i macierz uprawnień (rozdz. 12).Pokrewna funkcja to ocena sesji (POST /api/rag/grade-session), używana w debriefingu: na podstawie dziennika akcji, alarmów i punktacji model generuje narracyjny werdykt (excellent / good / acceptable / poor) z mocnymi stronami, obszarami poprawy i ryzykami, opierając się na wytycznych ERC/ESC/PTL/ASA i kontekście z bazy wiedzy (top-15).
Ponieważ wyjście modelu jest z natury zawodne, każda funkcja ma warstwę zaciskania i naprawy, niezależną od tego, co model faktycznie zwróci:
clipPhysiology, clipDrugEffect, clipDrugAdvisor przycinają offsety i delty do zakresów bezpiecznych (np. delta HR do ±80, MAP do ±60), niezależnie od tego, jak duże wartości poda model.normDrugTarget normalizuje przypadki, gdy model kopiuje literalnie enum („hr|mapMmHg|...").sanitizeModelJson usuwa komentarze i przecinki końcowe, a fallbacki wyciągają zbalansowany blok nawiasów lub domykają JSON obcięty limitem tokenów (śledzenie stosu {/[), co jest częste przy lokalnych modelach.Każde wywołanie jest cache'owane i audytowane. ai_response_cache kluczuje po sha256(modelId | useCase | input) i zlicza trafienia, więc deterministyczny input nie płaci drugi raz. ai_queries to trwały dziennik: prompt i odpowiedź (przycięte do 20 000 znaków), tokeny wejścia i wyjścia, koszt w millicentach (1/100000 USD, liczony z tabeli cen per model; endpoint lokalny = 0), latencja, status cache oraz kod błędu. Błąd zapisu audytu jest niekrytyczny i nie blokuje samego AI.
Wyłączenie AI degraduje system bezpiecznie, do w pełni deterministycznego rdzenia, a nie do stanu nieokreślonego. Zachowanie jest jawne:
complete() rzuca wyjątek, zamiast działać po cichu, a endpointy zwracają 503 ai_disabled lub 502 ai_failed z czytelnym komunikatem,searchKnowledge bez klucza embeddingów zwraca pustą listę,Warstwa AI jest przydatna, ale jej możliwości są celowo ograniczone, a granice warto nazwać wprost:
Backend (Fastify + Drizzle ORM + PostgreSQL) utrwala 22 tabele (apps/api/src/db/schema/). Kluczowe grupy:
sessions (status running/paused/completed/aborted), session_events (dziennik komend), session_snapshots (okresowe migawki pełnego PatientState z alarmami, podstawa wznowienia i replayu).patients plus tabele powiązane: choroby, leki przewlekłe, alergie, wyniki laboratoryjne, zabiegi.drugs (parametry PK/PD w JSONB), drug_interactions, diseases (z physiologyImpact w JSONB).exam_templates (timeline + checklista), exam_sessions (dziennik akcji, status).knowledge_sources, knowledge_chunks (embedding 1024d), ai_response_cache, ai_queries.app_settings, users, organizations.Sesja jest tworzona przy starcie, migawki zapisywane okresowo (preview: HR/MAP/SpO₂ oraz czas symulacji i osi zdarzeń), a wznowienie inicjuje stan z ostatniej migawki zamiast od zera. Rozszerzenie pgvector inicjowane jest w obrazie Dockera; migracje Drizzle numerowane są od 0000 do 0007 (ostatnia: zmiana wymiaru embeddingu 1536 → 1024).
Poprawność nie jest deklaracją, lecz zestawem testów. Wszystkie 26 rytmów przeszło wieloetapowy audyt fizjologiczny (morfologia EKG oraz spójność przebieg–liczba), a każdy wykryty defekt naprawiono i zabezpieczono testem regresyjnym. Pakiet liczy obecnie 196 testów silnika (fizjologia, alarmy, farmakodynamika, rytmy, macierz uprawnień), a wszystkie pakiety przechodzą statyczną kontrolę typów w trybie ścisłym.
Wśród utrwalonych inwariantów są między innymi:
Sposób pracy, najpierw audyt, potem test pod każdy defekt, jest bezpośrednim przełożeniem zasady z rozdziału 1: skoro liczy się spójność, to właśnie spójność jest testowana, nie wygląd pojedynczej krzywej.
Podział między kodem a wiedzą jest świadomy. Kod trzyma tylko „maszynowe profile napędzające parametry"; patofizjologia, tabele etapów, cytowania i normy mieszkają w vaulcie Obsidian i są stamtąd ingestowane do bazy wektorowej. Kod odsyła do vaulta przez pola sourceRef.
Źródła, na których opiera się warstwa kliniczna:
Dokładne dane bibliograficzne tych pozycji zebrano w rozdziale 18 z adnotacją o konieczności weryfikacji.
Uczciwy rachunek granic tego, co system osiągnął.
Wszystkie dane techniczne, wzory i stałe w tym dokumencie pochodzą z repozytorium (commit 8ee2402, wersja 0.3.1). Kluczowe pliki: packages/core/src/synth/* (synteza sygnałów), physiology-engine.ts (model fizjologii), utils/perfusion.ts (inwariant perfuzji), data/{clinical-events,arrest-model}.ts (zdarzenia i NZK), pharma/drug-pd.ts i contraindications.ts (farmakodynamika i kontraindykacje), engine/{alarm-engine,defibrillator,event-plausibility}.ts, capabilities.ts (macierz uprawnień), apps/api/src/db/schema/* (baza), services/{ai,knowledge}.ts (warstwa AI).
Poniższe pozycje przywołano z pamięci i należy potwierdzić ich dane bibliograficzne przed publikacją:
Dodatkowe pozycje z zakresu efektywności symulacji medycznej (ramowy model Jeffries, badanie NCSBN, metaanalizy Cook i wsp.) są istotne dla ewentualnego rozdziału o walidacji dydaktycznej (rozdz. 17), ich dane bibliograficzne również wymagają weryfikacji.
Kardo to narzędzie wyłącznie edukacyjne i szkoleniowe. Nie jest wyrobem medycznym w rozumieniu rozporządzenia MDR (UE) 2017/745 i nie może być używane do diagnostyki, monitorowania ani decyzji klinicznych u rzeczywistych pacjentów.