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

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 [#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 [#engines]

| Engine                               | When                                                                                  | Label 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 [#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`) [#procedures-labimport-all-orgprocedure]

| Procedure                                  | Purpose / 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 [#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 [#verification]

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

