Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

Lab report import and AI engines

How a fictional DEMO lab PDF becomes a reviewed Result Set: browser conversion, the rules parser, the optional AI extractor with its source check, and the AI plan summary.

Markdown

A patient uploads a lab report PDF on /import, reviews the values the server read from it, and confirms them. The confirmed values become a released Result Set (source: import) with its Energy Score, plan and summary. Everything here is a demo: the two sample reports are fictional and carry the watermark "DEMO – KEIN ECHTER BEFUND".

No file storage

The PDF never leaves the browser. The existing AnyDoc Web Worker (apps/web/src/features/documents/documents.worker.ts) converts it to Markdown, and only that text (at most 192 KiB) is sent to labImport.start. The server keeps the file name, the Markdown's SHA-256 and size, and the extracted candidates in lab_import; it stores neither the PDF nor the Markdown. That works without R2 credentials and keeps less health data. A scanned PDF without a text layer fails in the browser with needsOcr.

Engines

EngineWhenLabel in the UI
Rules parser (lab-import.rules.ts)always"Regelbasiert (Demo-Fallback)"
AI extractor (lab-import.ai.ts)AI_PROVIDER and its key are set, and the request does not ask for engine: "rules""KI (OpenAI gpt-6-luna)": provider and the model id that answered

The rules parser reads AnyDoc's Markdown as a stream of segments: a results table ("Parameter | Ergebnis | …") row by row, any other table (a page in columns) column by column, so a value that wrapped into the next row still follows its label. One expression finds label value unit range entries; the label's longest tail that is a known alias (biomarker_alias, normalised with labels.ts) names the Biomarker, and factor_to_canonical / offset convert the value and the range into the canonical unit. It repairs AnyDoc's split digits in number cells ("8 8" → 88), reads </> qualifiers, and warns about unknown parameters, unknown units and split rows. Confidence: 1.0 with unit and range, 0.8 with unit only (or a qualifier), 0.6 with the unit inferred from the catalog.

The AI extractor sends the catalog (codes, canonical units, labels) and the report with the header's name, date of birth and address redacted, through extractStructured (platform/ai/ai.structured.ts, TanStack AI chat() with outputSchema). The model returns codes, values and units as printed plus the line it read them from; it never converts units. A value is kept only if it appears in the cited text and that text exists in the report (a split row may be cited as two adjacent pieces). Per Biomarker the checked AI value wins, unless the rules parser read a different value with confidence 1.0: then the rules value stays and the conflict is flagged. The rules parser fills what the model missed. A timeout (AI_TIMEOUT_MS), an error or an invalid answer leaves the rules result with the warning "KI nicht erreichbar, regelbasiertes Ergebnis". No database transaction is open during the call.

The review screen is mandatory: no import becomes a Result Set without the User confirming the rows.

Plan summary

At release (admin advance to results_ready, the patient's demo control, or an import confirm) the rules engine writes the plan and its summary in the transaction. Afterwards, outside it, refreshPlanSummary (health-plan.service.ts) asks the model for a summary of at most 120 words in "du"-form that mentions only the given values and measures. A post-check rejects a text that is too long or names a Biomarker that is not in the set; the rules text then stays. healthPlan.regenerateSummary({ setId }) runs the same step on demand. plan_summary.source and engine_detail ("OpenAI gpt-6-luna" or "Regelbasiert (Demo-Fallback)") tell the UI which engine wrote it.

Procedures (labImport, all orgProcedure)

ProcedurePurpose / errors
engine()The engine and its label, before upload
start({ fileName, markdown, engine? })Read the report; engine: "rules" skips the model. NoMarkersFound → BAD_REQUEST
get({ id }), list(), discard({ id })The import with candidates (classified with the profile's ranges) and warnings. ImportNotFound → NOT_FOUND
confirm({ id, sampledAt, measurements })Released Result Set, plan and summary. ImportAlreadyConfirmed → CONFLICT, UnknownBiomarker → BAD_REQUEST

Sample reports and fixtures

scripts/demo/lab-reports/*.html are rendered to apps/web/public/demo/lab-reports/*.pdf by node scripts/demo/render-lab-reports.mjs (headless Chrome). demo-befund-2026-basis.pdf is a results table with the 34 Empower markers; demo-befund-2026-retest-si-einheiten.pdf is a two-column page in SI units with a <0,5 qualifier, an unknown parameter (Lipase) and a split row. Their AnyDoc Markdown is committed in packages/api/src/features/lab-import/fixtures/ with the exact expected markers; lab-import.fixtures.test.ts fails when AnyDoc's output drifts (regenerate with UPDATE_LAB_FIXTURES=1).

Verification

pnpm --filter @template-cloudflare/api exec vitest run src/features/lab-import src/features/health-plan src/platform/ai
node scripts/test-local-postgres.mjs exec pnpm --filter @template-cloudflare/api exec vitest run src/features/lab-import
# one real model call with the local key (prints engine, model id, counts, warning codes only)
pnpm --filter @template-cloudflare/api exec node --import tsx scripts/lab-import-ai-smoke.mjs

Model tests use TanStack AI's fakeText adapter; no test calls a live model.

Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.