mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge origin/release/v1.46.0 into feat/v146-ai
This commit is contained in:
commit
96eeec6080
47 files changed
+2588
-1122
No files matched your search
@@ -4,16 +4,14 @@
|
||||
*
|
||||
* The two answer a transposed pair differently on purpose. Extraction cannot
|
||||
* reject — every field in that schema is `.catch()`ed so one bad value does
|
||||
* not sink a 120-fact document — so it drops the pair and keeps the verbatim
|
||||
* not sink a long document — so it drops the pair and keeps the verbatim
|
||||
* string. An edit is a person looking at both numbers, so it refuses and says
|
||||
* why, exactly as the three lab schemas do.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractedFactRawSchema,
|
||||
inboundFactEditSchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { inboundFactEditSchema } from "@/lib/validations/inbound-documents";
|
||||
import { extractedFactRawSchema } from "@/lib/validations/inbound-extraction";
|
||||
|
||||
const baseRaw = {
|
||||
type: "OBSERVATION" as const,
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
* STATED assertion about the patient, never as the app's conclusion.
|
||||
*
|
||||
* This module holds:
|
||||
* - `inboundExtractionSchema` — the schema the provider's JSON is validated
|
||||
* against (UNTRUSTED model output). Liberal on shape; the review screen is
|
||||
* the safety boundary.
|
||||
* - the fact types and code systems a provider may name (its JSON is
|
||||
* validated in `inbound-extraction.ts`).
|
||||
* - the staged-fact DTO shapes the routes return + the UI consumes.
|
||||
* - `inboundFactEditSchema` — the per-fact correction the review screen sends
|
||||
* before approval (fixes OCR / units / dates / codes).
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
} from "@/lib/documents/inbound-constants";
|
||||
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { OCR_MAX_ROWS } from "@/lib/validations/labs-ocr";
|
||||
|
||||
// The constants and label lists a client needs live in a zod-free module, so
|
||||
// a component that wants the list of document kinds does not pull the schema
|
||||
@@ -48,8 +48,13 @@ export {
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/documents/inbound-constants";
|
||||
|
||||
/** Max facts a single document may stage / confirm. A dense letter is ~40. */
|
||||
export const INBOUND_MAX_FACTS = 120;
|
||||
/**
|
||||
* Max facts a single document may stage / confirm. A dense letter is ~40, but
|
||||
* a multi-page lab report read through the document runs to the hundreds of
|
||||
* readings the lab scan takes, so it is the lab scan's own cap: the same
|
||||
* report keeps the same rows whichever way it is read.
|
||||
*/
|
||||
export const INBOUND_MAX_FACTS = OCR_MAX_ROWS;
|
||||
|
||||
/**
|
||||
* Confidence floor. A fact scoring below this fails closed: it is staged with
|
||||
@@ -154,93 +159,6 @@ export const INBOUND_CODE_SYSTEMS = [
|
||||
] as const;
|
||||
export type InboundCodeSystem = (typeof INBOUND_CODE_SYSTEMS)[number];
|
||||
|
||||
const confidenceScore = z.number().min(0).max(1).catch(0);
|
||||
const nullableText = (max: number) =>
|
||||
z.string().trim().min(1).max(max).nullable().catch(null);
|
||||
|
||||
/**
|
||||
* One fact as the PROVIDER returns it (untrusted). A single flat shape across
|
||||
* the three fact types so the wire stays simple; the route maps it to the
|
||||
* per-type staged payload. Everything is `.catch`-guarded so a malformed field
|
||||
* degrades to null + low confidence rather than 422-ing the whole extraction.
|
||||
*/
|
||||
const extractedFactRawFields = z.object({
|
||||
type: z.enum(INBOUND_FACT_TYPES),
|
||||
/** Verbatim label / diagnosis text / medication name as written. */
|
||||
label: z.string().trim().min(1).max(300).catch(""),
|
||||
/** STATED code, present only when the document writes one. */
|
||||
code: nullableText(64),
|
||||
codeSystem: z.enum(INBOUND_CODE_SYSTEMS).nullable().catch(null),
|
||||
/** Condition: stated clinical / verification status (verbatim). */
|
||||
clinicalStatus: nullableText(64),
|
||||
verificationStatus: nullableText(64),
|
||||
/** Observation: numeric XOR qualitative value, unit, stated ref bounds. */
|
||||
value: z.number().finite().nullable().catch(null),
|
||||
valueText: nullableText(300),
|
||||
unit: nullableText(80),
|
||||
referenceLow: z.number().finite().nullable().catch(null),
|
||||
referenceHigh: z.number().finite().nullable().catch(null),
|
||||
/**
|
||||
* Observation: the reference range EXACTLY as printed, verbatim. A report
|
||||
* writes its window in prose ("3,5 - 5,0", "< 5", "bis 5,0", "negativ"), and
|
||||
* `referenceLow` / `referenceHigh` above can only carry the cases that
|
||||
* reduce to two numbers. This field is what stops every other case from
|
||||
* being dropped on the floor — the string is kept whether or not the parser
|
||||
* gets bounds out of it.
|
||||
*/
|
||||
referenceText: nullableText(120),
|
||||
/** MedicationStatement: dose + stated status (verbatim). */
|
||||
dose: nullableText(120),
|
||||
medicationStatus: nullableText(64),
|
||||
/** A stated date for the fact (onset / effective / report), ISO or null. */
|
||||
effectiveDate: z.string().nullable().catch(null),
|
||||
/** Provenance: the verbatim source span this fact came from. */
|
||||
sourceText: z.string().trim().max(2000).catch(""),
|
||||
/** Optional 0-based page index the fact was read from. */
|
||||
page: z.number().int().min(0).max(10000).nullable().catch(null),
|
||||
/** The model's self-reported overall confidence for this fact (0..1). */
|
||||
confidence: confidenceScore.default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* A window whose floor sits above its ceiling cannot be true, so it is dropped
|
||||
* rather than repaired. Swapping the pair would invent a range the report never
|
||||
* printed, and this schema cannot reject the fact the way `labs.ts` does —
|
||||
* every field above is `.catch()`ed precisely so one bad value does not sink a
|
||||
* whole extraction. `referenceText` survives either way, so nothing the
|
||||
* document actually stated is lost: the verbatim string is still there for a
|
||||
* person to read on the review screen.
|
||||
*
|
||||
* `parseReferenceRange` already answers a transposed PRINTED window this way.
|
||||
* These are the numbers the model reported instead, and they travelled
|
||||
* unchecked: an integration run against a real Postgres wrote a 100/30 pair
|
||||
* onto the row AND onto the freshly minted catalog marker, which then handed
|
||||
* the impossible window to every later reading of that analyte.
|
||||
*/
|
||||
export const extractedFactRawSchema = extractedFactRawFields.transform(
|
||||
(fact) => {
|
||||
if (
|
||||
fact.referenceLow !== null &&
|
||||
fact.referenceHigh !== null &&
|
||||
fact.referenceLow > fact.referenceHigh
|
||||
) {
|
||||
return { ...fact, referenceLow: null, referenceHigh: null };
|
||||
}
|
||||
return fact;
|
||||
},
|
||||
);
|
||||
|
||||
export type ExtractedFactRaw = z.infer<typeof extractedFactRawSchema>;
|
||||
|
||||
/** The full JSON envelope the provider returns. */
|
||||
export const inboundExtractionSchema = z.object({
|
||||
reportDate: z.string().nullable().catch(null),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).nullable().catch(null),
|
||||
facts: z.array(extractedFactRawSchema).max(INBOUND_MAX_FACTS).catch([]),
|
||||
});
|
||||
|
||||
export type InboundExtraction = z.infer<typeof inboundExtractionSchema>;
|
||||
|
||||
// ─── Staged-fact payloads (FHIR-staged, stated status only) ────────────────
|
||||
|
||||
/** Per-field provenance carried on every staged fact. */
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The provider's answer to a document extraction: UNTRUSTED model output,
|
||||
* validated fact by fact. Liberal on shape; the review screen is the safety
|
||||
* boundary. Split from `inbound-documents.ts`, which holds the staged-fact
|
||||
* DTOs, the edit and the confirm contracts.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/documents/inbound-constants";
|
||||
import {
|
||||
INBOUND_CODE_SYSTEMS,
|
||||
INBOUND_FACT_TYPES,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
const confidenceScore = z.number().min(0).max(1).catch(0);
|
||||
const nullableText = (max: number) =>
|
||||
z.string().trim().min(1).max(max).nullable().catch(null);
|
||||
|
||||
/**
|
||||
* One fact as the PROVIDER returns it (untrusted). A single flat shape across
|
||||
* the three fact types so the wire stays simple; the route maps it to the
|
||||
* per-type staged payload. Everything is `.catch`-guarded so a malformed field
|
||||
* degrades to null + low confidence rather than 422-ing the whole extraction.
|
||||
*/
|
||||
const extractedFactRawFields = z.object({
|
||||
type: z.enum(INBOUND_FACT_TYPES),
|
||||
/** Verbatim label / diagnosis text / medication name as written. */
|
||||
label: z.string().trim().min(1).max(300).catch(""),
|
||||
/** STATED code, present only when the document writes one. */
|
||||
code: nullableText(64),
|
||||
codeSystem: z.enum(INBOUND_CODE_SYSTEMS).nullable().catch(null),
|
||||
/** Condition: stated clinical / verification status (verbatim). */
|
||||
clinicalStatus: nullableText(64),
|
||||
verificationStatus: nullableText(64),
|
||||
/** Observation: numeric XOR qualitative value, unit, stated ref bounds. */
|
||||
value: z.number().finite().nullable().catch(null),
|
||||
valueText: nullableText(300),
|
||||
unit: nullableText(80),
|
||||
referenceLow: z.number().finite().nullable().catch(null),
|
||||
referenceHigh: z.number().finite().nullable().catch(null),
|
||||
/**
|
||||
* Observation: the reference range EXACTLY as printed, verbatim. A report
|
||||
* writes its window in prose ("3,5 - 5,0", "< 5", "bis 5,0", "negativ"), and
|
||||
* `referenceLow` / `referenceHigh` above can only carry the cases that
|
||||
* reduce to two numbers. This field is what stops every other case from
|
||||
* being dropped on the floor — the string is kept whether or not the parser
|
||||
* gets bounds out of it.
|
||||
*/
|
||||
referenceText: nullableText(120),
|
||||
/** MedicationStatement: dose + stated status (verbatim). */
|
||||
dose: nullableText(120),
|
||||
medicationStatus: nullableText(64),
|
||||
/** A stated date for the fact (onset / effective / report), ISO or null. */
|
||||
effectiveDate: z.string().nullable().catch(null),
|
||||
/** Provenance: the verbatim source span this fact came from. */
|
||||
sourceText: z.string().trim().max(2000).catch(""),
|
||||
/** Optional 0-based page index the fact was read from. */
|
||||
page: z.number().int().min(0).max(10000).nullable().catch(null),
|
||||
/** The model's self-reported overall confidence for this fact (0..1). */
|
||||
confidence: confidenceScore.default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* A window whose floor sits above its ceiling cannot be true, so it is dropped
|
||||
* rather than repaired. Swapping the pair would invent a range the report never
|
||||
* printed, and this schema cannot reject the fact the way `labs.ts` does —
|
||||
* every field above is `.catch()`ed precisely so one bad value does not sink a
|
||||
* whole extraction. `referenceText` survives either way, so nothing the
|
||||
* document actually stated is lost: the verbatim string is still there for a
|
||||
* person to read on the review screen.
|
||||
*
|
||||
* `parseReferenceRange` already answers a transposed PRINTED window this way.
|
||||
* These are the numbers the model reported instead, and they travelled
|
||||
* unchecked: an integration run against a real Postgres wrote a 100/30 pair
|
||||
* onto the row AND onto the freshly minted catalog marker, which then handed
|
||||
* the impossible window to every later reading of that analyte.
|
||||
*/
|
||||
export const extractedFactRawSchema = extractedFactRawFields.transform(
|
||||
(fact) => {
|
||||
if (
|
||||
fact.referenceLow !== null &&
|
||||
fact.referenceHigh !== null &&
|
||||
fact.referenceLow > fact.referenceHigh
|
||||
) {
|
||||
return { ...fact, referenceLow: null, referenceHigh: null };
|
||||
}
|
||||
return fact;
|
||||
},
|
||||
);
|
||||
|
||||
export type ExtractedFactRaw = z.infer<typeof extractedFactRawSchema>;
|
||||
|
||||
/**
|
||||
* The JSON envelope the provider returns. Its facts are validated one by one
|
||||
* (`parseInboundExtraction`), so one malformed fact is dropped on its own
|
||||
* instead of taking every other fact of the answer with it.
|
||||
*/
|
||||
const inboundEnvelopeSchema = z.object({
|
||||
reportDate: z.string().nullable().catch(null),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).nullable().catch(null),
|
||||
facts: z.array(z.unknown()).catch([]),
|
||||
});
|
||||
|
||||
/** A provider answer, its facts validated. */
|
||||
export interface InboundExtraction {
|
||||
reportDate: string | null;
|
||||
kind: InboundDocumentKindValue | null;
|
||||
facts: ExtractedFactRaw[];
|
||||
/** Facts in the answer that did not match the schema and were dropped. */
|
||||
droppedFacts: number;
|
||||
}
|
||||
|
||||
/** Validate one provider answer fact by fact; null when it is not an envelope at all. */
|
||||
export function parseInboundExtraction(
|
||||
json: unknown,
|
||||
): InboundExtraction | null {
|
||||
const envelope = inboundEnvelopeSchema.safeParse(json);
|
||||
if (!envelope.success) return null;
|
||||
const facts: ExtractedFactRaw[] = [];
|
||||
let droppedFacts = 0;
|
||||
for (const raw of envelope.data.facts) {
|
||||
const fact = extractedFactRawSchema.safeParse(raw);
|
||||
if (fact.success) facts.push(fact.data);
|
||||
else droppedFacts += 1;
|
||||
}
|
||||
return {
|
||||
reportDate: envelope.data.reportDate,
|
||||
kind: envelope.data.kind,
|
||||
facts,
|
||||
droppedFacts,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user