Agentic system for EMS quality review — turns Patient Care Reports (PCR), body-cam video, dispatch audio, and CAD records into a reviewer-ready QI Case Review with timeline reconciliation, protocol checks, clinical assessment, discrepancy findings, and structured recommendations.
→ Pitch: docs/PITCH.md → Architecture (mermaid diagrams): docs/ARCHITECTURE.md → Phased roadmap: docs/PLAN.md — every phase is ✅ → Build log: docs/PROGRESS.md
git clone https://github.com/Asyboi/emt.git calyx && cd calyx
# Terminal A — backend
cd backend && uv sync && uv run uvicorn app.main:app --reload
# Terminal B — frontend
cd frontend && npm install && npm run dev
# Open
open http://localhost:5173The frontend lands on a marketing page — click into the app and either pick a cached case (no API keys needed) or upload a new one to trigger the live pipeline. The QI Review page shows a 3D ambulance simulation that follows the body-cam timeline; clicking the Report button opens a bento grid of section tiles (timeline, findings, protocol checks, recommendations, etc.). Click any Finding card → the body-cam seeks to that second AND the matching PCR sentence is highlighted.
| Tool | Version | Install |
|---|---|---|
| Python | ≥ 3.11 | system / pyenv |
uv |
latest | curl -LsSf https://astral.sh/uv/install.sh | sh |
| Node.js | ≥ 18 (LTS recommended) | nvm / fnm / system |
| npm | bundled with Node | — |
uv installs to ~/.local/bin/uv by default; make sure that's on your
PATH (or use the absolute path).
The frontend exposes two data sources (toggled by URL param) and the backend exposes two streaming modes (live or replay).
Default mode. The frontend reads bundled mock fixtures from
src/mock/mock_data.ts — useful for UI work without running the
backend at all.
cd frontend && npm install && npm run dev
# Browser: http://localhost:5173?localWhat it does:
- Hits Anthropic, Google Gemini, and ElevenLabs.
- Each pipeline stage runs for real and the result is cached to
cases/<id>/review.json. Subsequent loads hit that cache; the Reset action clears it and forces a fresh run. - The on-disk schema is
QICaseReview(the previousAARDrafthas been retired). On startup the backend auto-migrates legacycases/*/aar.jsonfiles toreview.jsonif they parse against the current schema. - For demos, run
scripts/warm_cache.pyonce per case to pre-bake the four slow upstream stages (CAD/PCR/video/audio) intocases/<id>/upstream_cache.json. The next live run replays those stages instantly while the four downstream agentic stages (reconciliation → protocol-check → findings → drafting) still run live against the LLMs. SeeDEMO_UPSTREAM_CACHE_CASE_IDbelow.
How to start it:
# 1. Configure API keys (see "Environment" below)
cp .env.example .env
$EDITOR .env
# 2. Start backend + frontend
cd backend && uv sync && uv run uvicorn app.main:app --reload &
cd frontend && npm install && npm run dev
# 3. Browser — `?remote` switches the frontend to the live API
http://localhost:5173?remoteUse New Report in the UI to upload an ePCR (PDF/XML), CAD JSON,
audio, and video — or pick a previously confirmed PCR from the
saved-PCR picker on the same page. Or use PCR Auto-Draft to skip
the ePCR entirely and have Calyx generate one from media before
running QI review. Confirmed drafts also show up in the Archive
tab and have a read-only view at /pcr/:caseId.
GET /api/cases/{id}/stream?demo=1 replays the cached
QICaseReview over Server-Sent Events with the same event shape as
the live pipeline (running/complete per stage with small synthetic
delays). Useful for demo videos and offline presentations whenever a
review is already cached on disk. The frontend's demo navigation
strip only appears when ?demo=1 is in the URL.
Copy .env.example to .env at the repo root (not inside
backend/). The backend reads ../.env first then ./.env (see
backend/app/config.py).
| Variable | Used by | Required for live mode? |
|---|---|---|
ANTHROPIC_API_KEY |
PCR parser, reconciliation, findings, drafting, audio events, PCR auto-draft | Yes |
GOOGLE_API_KEY |
Video analyzer (Gemini 2.5 Flash) | Optional — without it, video stage returns [] |
ELEVENLABS_API_KEY |
Audio analyzer (Scribe v1) | Optional — without it, audio stage returns [] |
OPENAI_API_KEY |
(legacy slot, currently unused) | No |
CASES_DIR |
Backend case loader | Optional — defaults to ../cases |
PROTOCOLS_DIR |
Protocol checker | Optional — defaults to ../protocols |
FIXTURES_DIR |
Backend stubs / seed | Optional — defaults to ../fixtures |
FRONTEND_ORIGINS |
CORS allowlist (comma-separated) | Optional — defaults to http://localhost:5173 |
DEMO_UPSTREAM_CACHE_CASE_ID |
Reuse one case's warmed upstream_cache.json for fresh uploads (so PCR Auto-Draft demos skip Gemini/Scribe) |
Optional — defaults to case_01 |
VITE_API_URL |
Frontend production build only | Set this when deploying the frontend separately from the backend |
VITE_DATA_SOURCE |
Frontend default source (local or remote) |
Optional — overridden by ?local / ?remote URL params |
The pipeline degrades gracefully when keys / media are missing — it
won't crash, it just emits empty events for stages it can't run. So
ANTHROPIC_API_KEY alone is enough for a meaningful live demo.
Real cases live in cases/case_NN/. Drop these in for the audio,
video, and CAD stages to do real work:
pcr.md— plain-text PCR (auto-generated as a placeholder if missing)audio.mp3/audio.wav/audio.m4a— dispatch audio (ElevenLabs Scribe)video.mp4(≤ 50 MB) — body-cam video (Gemini File API)cad.json— NYC EMS CAD record (no LLM, deterministic Pydantic parse)
Without those files the corresponding stages emit empty events. The
POST /api/cases endpoint accepts uploads for any combination of
ePCR / audio / video / CAD and creates a new case_NN directory
under CASES_DIR.
The cases/ layout:
cases/
case_01/
pcr.md # plain-text PCR (auto-placeholder if missing)
pcr.original.md # original ePCR text (when uploaded)
pcr_draft.json # PCR auto-draft state (status, markdown, edits)
cad.json # NYC EMS CAD record
review.json # cached QICaseReview (gitignored)
upstream_cache.json # warmed CAD/PCR/video/audio results (gitignored)
audio.mp3 # gitignored
video.mp4 # gitignored
case_04/
case_05/
case_06/
Confirmed PCR auto-drafts are also persisted to pcr_store/<case>.json
(sibling to cases/) so they can be browsed across cases via
GET /api/pcr-drafts.
cd backend
uv run pytest -vLLM-gated tests skip without API keys.
cd backend
uv run pytest tests/test_case_cache.py -vcd backend
uv run ruff check app tests # lint
uv run ruff format app tests # formatcd frontend
npm run typecheck # tsc --noEmit
npm run build # typecheck + vite build → dist/
npm run test # vitest (adapters + source tests)Useful for quick smoke tests of the live pipeline:
cd backend
uv run python scripts/run_pipeline.py case_01
uv run python scripts/run_pipeline.py case_01 --summaryThis loads case_01, runs the eight stages (CAD + PCR + video +
audio in parallel, then reconciliation → protocol-check → findings →
drafting), and prints the resulting QICaseReview as JSON. Same
.env rules apply.
cd backend
uv run python scripts/warm_cache.py case_01 # run if missing
uv run python scripts/warm_cache.py case_01 --force # rebuildWrites cases/<id>/upstream_cache.json with the CAD/PCR/video/audio
results so subsequent live runs (and any new uploads that point at
this case via DEMO_UPSTREAM_CACHE_CASE_ID) skip Gemini and Scribe
and only burn LLM budget on the four downstream agentic stages.
curl -X DELETE http://localhost:8000/api/cases/case_01/reviewcase_01 re-seeds itself from fixtures/sample_qi_review.json on
the next read — that's intentional, so the demo case never goes
empty.
| Method | Path | Notes |
|---|---|---|
GET |
/health |
Liveness probe |
GET |
/api/cases |
List all cases |
POST |
/api/cases |
Create a case from uploaded ePCR / audio / video / CAD; also accepts pcr_source_case_id to clone a saved PCR's body into the new case |
GET |
/api/cases/{id} |
Get case metadata |
GET |
/api/cases/{id}/pcr |
{ "content": "..." } markdown |
GET |
/api/cases/{id}/review |
Cached QICaseReview, 404 if none |
DELETE |
/api/cases/{id}/review |
Clear the cache (Reset) |
GET |
/api/cases/{id}/video |
FileResponse of the body-cam video (range support) |
POST |
/api/cases/{id}/process |
Background job (returns {job_id, case_id}) |
GET |
/api/cases/{id}/stream |
SSE pipeline stream (live; replays warmed upstream stages from cache when present) |
GET |
/api/cases/{id}/stream?demo=1 |
SSE replay of the cached QICaseReview |
POST |
/api/cases/{id}/pcr-draft |
Trigger PCR auto-draft (video + audio + CAD → markdown) |
GET |
/api/cases/{id}/pcr-draft |
Poll the current PCRDraft |
PATCH |
/api/cases/{id}/pcr-draft/confirm |
EMT confirms (and may edit) the drafted PCR |
GET |
/api/pcr-drafts |
List all confirmed drafts from pcr_store/ |
The SSE stream emits 16 progress events (running + complete per
stage × 8 stages) and one final complete event carrying the
QICaseReview. The replay (?demo=1) skips the CAD stage and emits
14 events for the remaining 7.
backend/ FastAPI + pipeline orchestrator (v0.3.0)
app/
main.py ASGI entrypoint (CORS, /health, /api/* routers, lifespan migration)
config.py pydantic-settings, reads ../.env
schemas.py **single source of truth** for data contracts
case_loader.py cases/ I/O, review.json cache + legacy aar.json migration
pcr_store.py persistent confirmed-PCR store (pcr_store/{case}.json)
upstream_cache.py warmed CAD/PCR/video/audio cache (demo skip-Gemini/Scribe path)
llm_clients.py Anthropic / Gemini / ElevenLabs wrappers
prompts.py all LLM prompts + tool schemas
api/
cases.py GET/POST/DELETE cases + review endpoints
pipeline.py POST process + GET SSE stream (incl. demo replay)
pcr_draft.py PCR auto-draft endpoints + saved-PCR list
pipeline/
orchestrator.py process_case(): cad/pcr/video/audio in parallel → reconcile → check → find → draft
cad_parser.py deterministic CAD JSON → CADRecord (no LLM)
pcr_parser.py Claude Haiku 4.5 — structured PCR extraction
video_analyzer.py Gemini 2.5 Flash — body-cam events (with size-cap fallback)
audio_analyzer.py ElevenLabs Scribe v1 → Haiku event extraction
reconciliation.py Claude Sonnet 4.6 — multi-source timeline merge
protocol_check.py deterministic ACLS rule engine (no LLM)
protocols.py protocol family selection from CAD call types
findings.py Claude Sonnet 4.6 — five-category grounded findings
drafting.py Claude Sonnet 4.6 — QICaseReview synthesis
pcr_drafter.py Claude Sonnet 4.6 — PCR auto-draft (pre-pipeline)
scripts/
run_pipeline.py CLI runner
warm_cache.py pre-bake upstream_cache.json for a case
tests/ pytest, asyncio_mode=auto
frontend/ Vite + React 18 + TS + Tailwind + React Router
src/
main.tsx bootstrap; demo session reload guard
app/
App.tsx <RouterProvider />
routes.tsx Landing + Layout + QIReviewLayout route tree
landing/ marketing page + sections
pages/ dashboard, new-report (qi-review), pcr-new, pcr-draft, pcr-view, processing, review, review-report, finalize, archive
components/
layout.tsx, app-navbar.tsx, demo-nav.tsx, qi-review-layout.tsx, filing.tsx
AmbulanceSimulation/ 3D ambulance + Mapbox route + event callouts (review page)
pipeline/ agentic processing visualization (AgentCard, ModelPill, ParallelBox, StageLogs, etc.)
section-tiles/ bento-grid previews on the report page
section-views/ full-screen section views (timeline, findings, recommendations, etc.)
ui/ shadcn-style primitives
figma/ imported figma assets
lib/
pcr-highlight.ts [UNCONFIRMED] highlighter, count, section parser (shared by draft + view)
pcr-template.ts PCR_BLANK_TEMPLATE for manual writing fallback
data/
api.ts API_BASE resolution
source.ts local (mock) vs remote (backend) DataSource
adapters.ts QICaseReview → IncidentReport UI shape
sse.ts useProcessingStream — SSE consumer
hooks.ts useIncident / useIncidentList
pcr-api.ts PCR auto-draft client
pcr-hooks.ts React hooks for PCR draft polling
approvals.ts per-section approval state for the report page
types/backend.ts **mirror of backend/app/schemas.py**
types.ts UI-facing IncidentReport / IncidentSummary types
mock/mock_data.ts offline mock fixtures (?local mode)
mock/mock_pcr.ts offline PCR auto-draft fixtures
styles/ tailwind.css, theme.css, fonts.css
vite.config.ts /api/* proxy to :8000, `figma:asset/` resolver
vitest.config.ts unit tests for adapters + source
cases/ case bundles (PCR/audio/video/cad/review.json/upstream_cache.json)
pcr_store/ persistent confirmed PCR drafts (created on first confirm)
protocols/ protocol definitions (placeholder — rules live in pipeline/protocol_check.py)
fixtures/sample_qi_review.json canonical demo QI review (seeded into case_01)
docs/ PLAN, PROGRESS, ARCHITECTURE, PITCH, DEPLOYMENT, audits
.env.example copy to .env at the repo root
The phased plan (docs/PLAN.md) maps to a clean 3-person split:
- Person 1 — Data:
cases/,protocols/,fixtures/, ground truth - Person 2 — Pipeline: backend FastAPI app, LLM orchestration, stages
- Person 3 — Frontend: Vite/React review UI
After Phase 1 locked the schemas, Phases 2 (backend) and 3 (frontend)
ran in parallel against the locked contract — same shape on both
sides, snake_case preserved across the wire so the frontend can do
JSON.parse(...) as QICaseReview without translation (the
adapters.ts layer reshapes it for UI components).
uv: command not found — uv installs to ~/.local/bin/uv.
Either add that to PATH or use the absolute path:
~/.local/bin/uv sync.
Backend starts but GET /api/cases/case_01/review 404s — the
seed file got deleted. Either click Process Case in the UI to
regenerate it, or cp fixtures/sample_qi_review.json cases/case_01/review.json.
Legacy aar.json warnings on startup — pre-Phase-6 caches are
auto-migrated to review.json by migrate_legacy_aar_caches() at
startup. Files that don't parse against the current QICaseReview
schema are deleted; the next pipeline run regenerates them.
Frontend says "No video for this case" — expected unless you've
dropped a real video into cases/<id>/. The body-cam pane falls
back to a placeholder; clicking findings still works (just without
the seek effect).
Live pipeline crashes on PCR parsing — your .env doesn't have
ANTHROPIC_API_KEY set, or the key is invalid. Sanity-check with:
cd backend && uv run python -c "from app.config import settings; print(bool(settings.ANTHROPIC_API_KEY))"SSE stream hangs — the Vite dev server proxies /api/* to
localhost:8000. If the backend isn't running in ?remote mode the
EventSource sits open until it times out. Switch to ?local for
mock-only UI work, or start the backend.
Demo run is too slow / burning Gemini quota — pre-warm the
upstream cache once with
uv run python scripts/warm_cache.py case_01. The next live SSE run
replays CAD/PCR/video/audio from upstream_cache.json and only the
four downstream agentic stages hit the LLM.
© 2026 Andres Lopez, Aslan Wang, Donggyu Yoon. All rights reserved.