Commit Graph
15 Commits
Author SHA1 Message Date
Marc-André Bombeck 4dcb041612 fix(labs): review a unit that was only a calc note, keep unseen category places
A document unit that held only "(calc)" read as no unit and went straight to the record; it now goes to review. Saving a category order keeps the saved keys not on screen after the shown ones. The changelog says what each fix does and no more.
2026-11-10 19:53:47 +01:00
Marc-André Bombeck 157809c24f fix(labs): drop a lab's calculated-value note from the unit (#1313)
A read that copied "(calc)" into the unit minted a marker in "g/dL (calc)" that refused the next report's "g/dL". The note is dropped at the read and ignored in the unit comparison; any other bracket still counts.
2026-11-10 18:37:01 +01:00
Marc-André Bombeck 763d6c600f fix(labs): bound each text-layer read of an unrenderable PDF
`planTextLayerReads` grouped whole pages into the reads the deadline
allows, with no bound on a read's size, while the text path refuses a part
past `OCR_TEXT_READ_MAX_CHARS`. A dense report in a request's three reads
went out as reads of 12 000 characters and more, which ends at the output
limit or loses rows.

`planTextLayerChunks` now cuts a page longer than one read at line
boundaries, keeps a page per read while the deadline allows that many, and
otherwise packs pages into reads of at most twice the surface's target
(8 000 characters for the lab scan, 5 000 for the document extraction).
What the reads cannot take is read from the start, and the pages left over
are reported with the pages without a text layer: `unreadPages` on the lab
scan, `extractionIncomplete` on a document.

Refs #1311
2026-11-03 23:24:38 +01:00
Marc-André Bombeck df6d20b696 fix(documents): read a PDF the server cannot render by its text layer
On a host whose CPU cannot run the page renderer (x64 without AVX2, arm64
musl without LSE/RCpc), every AI read of a PDF for a provider that only
takes images failed with "Scanning PDFs needs a Claude vision provider",
which says nothing about the cause. Most medical PDFs are produced
digitally and carry every word in their text layer, which pdf.js reads
without rendering.

`prepareVisionInput` now answers a text read (`mode: "text"`) when the
renderer is unavailable on this host, the provider cannot take the PDF
whole and the PDF has a usable text layer. Summary, suggest and the
background summary send that text through their existing text mode;
"Read with AI" and the index backfill write it as the local text index it
is, with no provider call; the automatic index leaves it to its local path.
`planVisionReads` does the same for the extraction and the lab scan: text
reads by page group, with anchored provenance, and the pages without a text
layer reported as `extractionIncomplete` on a document and as `unreadPages`
on a lab scan. A supplement label PDF is read from its text layer too.

A scan on such a host now answers `documents.inbound.pdfRendererUnavailable`
and `labs.ocr.pdfRendererUnavailable` (422): this server's processor cannot
run the page renderer, so a scanned PDF can only be read by a provider that
takes PDF files directly, or as photos. The `pdfNeedsAnthropic` codes are no
longer emitted, since with a working renderer every provider reads a PDF;
the recorded index outcome keeps its `pdf-needs-anthropic` value with
corrected copy.

The extraction path no longer folds a render failure of one document into
the capability error: like `prepareVisionInput` since #776 it reports
"The PDF pages couldn't be rendered for scanning." (`extractFailed`), and
the lab scan answers `labs.ocr.extractFailed`.

Hosts that render, and providers that take PDFs whole, are unchanged.

Refs #1311
2026-11-03 22:07:22 +01:00
Marc-André Bombeck a822aa5a0e fix(documents): send a read that states no unit itself to review
A unit of "1" is the explicit no-unit choice a person makes. When a
document read returns it on its own, the value is staged for review
instead of passing straight through, so a unit the read dropped never
lands as a dimensionless value.
2026-11-02 20:31:33 +01:00
Marc-André Bombeck d3257a1b6b fix(documents): tell people when the document reader is busy, and attribute its work
A read a person started now runs as interactive work of their account, and
the document jobs as background work of theirs, so the reader's queue can
order and cap them. When the reader is busy or stopped before it answered,
a lab scan, label read or document read fails with aiRuns.readerBusy (503,
nothing read or charged) instead of a misleading unreadable-file error, and
the index and summary jobs throw so the queue runs them again rather than
recording an outcome about the document.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck fd299cad61 fix(labs): plan every report read against a schedule
The lab scan runs only in the background worker, which hands it the
run's deadline, but its body still took the schedule as optional and
planned a missing one without a bound: a twenty-page report as forty
model calls. Nothing calls it that way today; the signature allowed the
next caller to, and so did every read planner beneath it.

The scan and the planners (maxReadsFor, planTextReads, planVisionReads,
planInboundTextReads, planInboundReads) now require a schedule. A run
with room for every read says so with an unbounded one; a request uses
SYNCHRONOUS_READ_SCHEDULE. No route, wire or response changes.
2026-10-25 02:58:35 +01:00
Marc-André Bombeck da4056ce05 fix(documents): key an undated fact on its report's date in the merge
The extraction merged facts across reads on a key whose only date was
the fact's own effective date. Facts without one collapsed across reads
of different reports, so the same value on two days staged once. An
undated fact is now keyed on its read's report date, falling back to
the document's, as the lab scan keys a row.

Refs #1257
2026-10-25 02:03:27 +01:00
Marc-André Bombeck 5a7bd95c7b fix(documents): read a long document through the lab scan's read core
Extracting lab values from a stored document still sent the whole text
in one call. A multi-page report produced more JSON than one call's
output ceiling, the answer was cut mid-fact, the parse failed twice, and
the read came back as 422 "Couldn't read the document. Try a clearer
copy." The lab scan had already learned to read the same report a page
or a part at a time, but each surface carried its own copy of the read,
so the document path never got it. It also ran under the shared 60 s
timeout, dropped every fact past 120 (or every fact when one did not
match the schema), and charged a flat reservation.

The read now lives once, in lib/documents/ocr-read.ts and ocr-plan.ts:
the per-read limits, the corrective retry, the salvage of a cut
answer's complete rows, the wire re-check and budget reservation before
each read, and the planning of a PDF a page at a time and text in parts
at line boundaries. The lab scan and the document extraction (the
extract route, its background run and the lab auto-staging) both read
through it, and the background run plans its reads on the same deadline
as a lab scan. The per-read budget moves to a shared metered budget.

The document path validates the answer fact by fact, keeps the lab
scan's row cap (400), and says why nothing came back: 422
documents.inbound.outputLimit when the reads stopped at the output
limit, 429 when the budget refused them, extractFailed only for answers
that were not the schema. A run whose reads were cut or not reached
reports `incomplete` on its result.

A guard holds the class: the output ceiling is set only in the core,
both surfaces read through runOcrReads, both runs share the deadline,
and a lab read and a document read go out under the same limits.

Refs #1257
2026-10-24 10:34:31 +02:00
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00
Marc-André Bombeck fd2bd67ff3 feat(labs): keep the reference range a report prints beside a value
A lab runs its own method on its own device, so the window a physician
evaluates a value against is the one printed next to it on that report,
not a catalog band averaged over every lab. Reading a report pulled the
value and dropped that range: the extraction asked for it, the commit
handed it to the biomarker resolver, and the resolver only uses it when
it mints a NEW marker. For every analyte the user already had — the
normal case after a first panel — the printed range was read and then
thrown away.

Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText`
to `LabResult` and carry the range from the report to the reading. The
text is stored whether or not numbers can be read out of it, because
"bis 5,0" and "negativ" state a window too; a range that could not be
parsed is kept verbatim, never replaced with invented bounds.

`resolveEffectiveReferenceRange` is the one place the precedence lives:
the source window wins for that single reading, and the catalog stays
the net for readings without one. A printed string with no derivable
bound does not displace the catalog. Every surface that judges a
reading routes through it — the API verdict, the doctor report, the
insights blocks and per-marker assessment, the coach snapshot, the MCP
read and the FHIR `referenceRange` — so a value cannot read in range on
one surface and out on another.

The API keeps `referenceLow` / `referenceHigh` as the window in force,
so a client that reads only those two renders the right range and the
right verdict with no change of its own; the catalog band, the source
window, its origin and a divergence flag ride alongside for clients
that want to show both.

Read side: the chart draws the source window as a second, visually
distinct band with the reading it came from named beneath it, the
reading rows name the printed range and the catalog band where the two
disagree, and the doctor-report PDF prints the report's own string with
a footnote naming the saved range. Entry side: one optional field on
the lab form, folded away until asked for, and the printed range is
editable on the scan review screen.

The range parser covers the notations German and English reports
actually print — two-sided, one-sided, comma or dot decimals, thousands
separators, trailing units — reads units without ever converting them,
and refuses to guess on anything ambiguous.
2026-08-07 21:40:41 +02:00
Marc-André Bombeck 8e938a4f45 fix(documents): anchor extracted-fact provenance to the real document span
Every staged fact carried a sourceText the model echoed back, stored verbatim
as 'the exact verbatim span of the document the fact came from'. Nothing
checked that the span existed. A paraphrase, a normalised rendering, or an
outright invention was persisted and shown to the reviewer as a transcription
of their own clinical record — an anchor pointing at nothing, or at a span the
document never contained.

Treat the echo as a lookup key instead. anchorSourceText resolves it against
the extracted text (exact, then whitespace-collapsed and case-folded to absorb
OCR reflow) and stores the span the document actually carries, with its offset.
A quote that matches nothing is dropped and the fact is flagged unanchored;
vision mode has no extracted text at all, so its facts are unanchored by
construction. Rows written before this resolve to unanchored on read — their
quote is preserved but never presented as checked.
2026-07-19 11:06:14 +02:00
Marc-André Bombeck 0511e36ac9 Fence user-controlled text in the Coach and document-extraction prompts
The Coach SNAPSHOT was spliced into the prompt bare. Its JSON leaves are not
all server-computed: lab analyte, panel and unit names are transcribed by a
model out of an uploaded PDF and stored verbatim as the biomarker name, so a
hostile document chooses those strings. The about-me block has been fenced
since v1.16.1; extend the same pattern rather than adding per-field escapes.

- Move the fence markers and the wrapping primitive into a shared data-fence
  module. Content is scrubbed against every known marker, not just its own
  pair, so text in one block cannot forge another block's boundary.
- Fence the SNAPSHOT with an explicit data/instruction frame.
- Fence the OCR text handed to the document extractor; the previous
  'OCR TEXT:' label was a label, not a boundary.
- Sanitise analyte, panel and unit at the labs source, which feeds both the
  snapshot prompt and the labs_read tool result, and the medication label in
  the adherence storyline.
- Capture a ---REMEMBER--- sentinel as proposed rather than active. It was
  the only model-driven write with no confirm step, and the reminder recall
  block fed it back into every later turn. Proposed reminders are already
  excluded from that block and already carry a confirm control.
2026-07-19 04:39:31 +02:00
Marc-André Bombeck 98ca0b9d61 feat(documents): extract structured facts from clinical documents
Ingest an uploaded doctor report / discharge letter through the existing
dedicated OCR/vision provider rails (the v1.22 document-scan provider/key,
reused — no new provider) and transcribe structured facts into staging.

The safety line is extract, never interpret: the prompt frames the document
as untrusted data to reproduce verbatim. Diagnoses map to Condition,
measured values to Observation, medications to MedicationStatement — with
STATED status only. A code survives only when the document writes it and its
system matches the resource (SNOMED/ICD-10 for a diagnosis, LOINC for a
value, RxNorm/ATC for a medication); nothing is inferred, no value is
range-flagged, no condition is linked to a medication. Every fact carries
per-field provenance and a confidence score; a fact below the confidence
floor fails closed — it cannot be approved until the user edits it.

Routes (apiHandler + Zod + session-narrowed userId, opt-in module-gated):
- POST/GET /api/documents/inbound — upload+extract (vision or local-OCR
  text), and list. The raw document is stored encrypted at rest.
- GET/DELETE /api/documents/inbound/[id] — review detail; soft-delete.
- PATCH .../facts/[factId] — correct a staged fact before approval.
- POST .../confirm — the only write path: approved facts flow into the
  labs / conditions / medications stores through their normal create paths;
  rejected facts are discarded; low-confidence facts are refused.
2026-06-28 16:57:06 +02:00