Files
HealthLog/src/lib/documents/__tests__/extract.test.ts
T
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

244 lines
7.5 KiB
TypeScript

/**
* v1.25 (W-DOCS-IN) — extraction mapping safety tests.
*
* The mapping is the safety boundary: it must reproduce STATED facts only and
* never let a code, value, or status leak across resource types or past the
* confidence gate. These tests pin "extract, never interpret" at the unit
* level with a canned provider response (no network).
*/
import { describe, expect, it } from "vitest";
import {
InboundExtractError,
runInboundExtraction,
} from "@/lib/documents/extract";
import type { AIProvider } from "@/lib/ai/types";
import type {
ConditionFactData,
MedicationStatementFactData,
ObservationFactData,
} from "@/lib/validations/inbound-documents";
/** A provider that returns one canned completion regardless of params. */
function fakeProvider(content: string): AIProvider {
return {
// Only `generateCompletion` is exercised by the extractor.
generateCompletion: async () => ({ content }),
} as unknown as AIProvider;
}
describe("runInboundExtraction — stated-status-only mapping", () => {
it("keeps a stated ICD-10 code on a Condition and drops a mismatched system", async () => {
const provider = fakeProvider(
JSON.stringify({
reportDate: "2026-01-02",
kind: "DOCTOR_REPORT",
facts: [
{
type: "CONDITION",
label: "Type 2 diabetes mellitus",
code: "E11.9",
codeSystem: "ICD10",
clinicalStatus: "active",
verificationStatus: "confirmed",
effectiveDate: "2025-12-01",
confidence: 0.9,
sourceText: "Dx: Type 2 diabetes mellitus (E11.9)",
},
{
type: "CONDITION",
label: "Some finding",
code: "12345-6",
codeSystem: "LOINC", // wrong system for a Condition → dropped
confidence: 0.9,
sourceText: "finding",
},
],
}),
);
const result = await runInboundExtraction({
provider,
providerType: "mock",
});
expect(result.reportDate).toBe("2026-01-02");
expect(result.facts).toHaveLength(2);
const first = result.facts[0]!.data as ConditionFactData;
expect(first.code).toBe("E11.9");
expect(first.codeSystem).toBe("ICD10");
expect(first.clinicalStatus).toBe("active");
expect(first.onsetDate).toBe("2025-12-01");
const second = result.facts[1]!.data as ConditionFactData;
expect(second.code).toBeNull();
expect(second.codeSystem).toBeNull();
});
it("never sets both value and valueText on an Observation and never range-flags", async () => {
const provider = fakeProvider(
JSON.stringify({
reportDate: null,
facts: [
{
type: "OBSERVATION",
label: "Glucose",
code: "2345-7",
codeSystem: "LOINC",
value: 95,
valueText: "normal", // model sent both → numeric wins, text dropped
unit: "mg/dL",
referenceLow: 70,
referenceHigh: 100,
effectiveDate: "2026-01-02",
confidence: 0.95,
sourceText: "Glucose 95 mg/dL (70-100)",
},
],
}),
);
const result = await runInboundExtraction({
provider,
providerType: "mock",
});
const obs = result.facts[0]!.data as ObservationFactData;
expect(obs.value).toBe(95);
expect(obs.valueText).toBeNull();
expect(obs.code).toBe("2345-7");
// Reference bounds ride along as stated — but no flag/severity is computed.
expect(obs.referenceLow).toBe(70);
expect(obs.referenceHigh).toBe(100);
expect(obs).not.toHaveProperty("rangeStatus");
expect(obs).not.toHaveProperty("flag");
});
it("routes a stated RxNorm code onto the medication and gates low confidence", async () => {
const provider = fakeProvider(
JSON.stringify({
facts: [
{
type: "MEDICATION_STATEMENT",
label: "Metformin",
code: "6809",
codeSystem: "RXNORM",
dose: "500 mg",
medicationStatus: "ongoing",
confidence: 0.3, // below the floor → needsReview
sourceText: "Metformin 500 mg",
},
],
}),
);
const result = await runInboundExtraction({
provider,
providerType: "mock",
});
const fact = result.facts[0]!;
expect(fact.needsReview).toBe(true);
const med = fact.data as MedicationStatementFactData;
expect(med.rxNormCode).toBe("6809");
expect(med.atcCode).toBeNull();
expect(med.dose).toBe("500 mg");
expect(med.statusStated).toBe("ongoing");
});
it("stores the document's real span, not the model's echo of it", async () => {
// The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied
// rendering of it. The stored provenance must be the document's characters.
const ocrText = "Labor 2026-01-02\nHämoglobin 14.2 g/dL (13.5-17.5)";
const provider = fakeProvider(
JSON.stringify({
facts: [
{
type: "OBSERVATION",
label: "Hämoglobin",
value: 14.2,
unit: "g/dL",
confidence: 0.9,
sourceText: "Hämoglobin 14.2 g/dL (13.5-17.5)",
},
],
}),
);
const result = await runInboundExtraction({
provider,
providerType: "mock",
ocrText,
});
const { provenance } = result.facts[0]!;
expect(provenance.anchored).toBe(true);
expect(provenance.sourceText).toBe("Hämoglobin 14.2 g/dL (13.5-17.5)");
expect(
ocrText.slice(
provenance.sourceOffset!,
provenance.sourceOffset! + provenance.sourceText.length,
),
).toBe(provenance.sourceText);
});
it("marks a fact unanchored rather than storing a quote it cannot locate", async () => {
const ocrText = "Labor 2026-01-02\nHämoglobin 14.2 g/dL";
const provider = fakeProvider(
JSON.stringify({
facts: [
{
type: "OBSERVATION",
label: "Hämoglobin",
value: 14.2,
unit: "g/dL",
confidence: 0.9,
// A quote the document never carried.
sourceText: "Haemoglobin was measured at 14.2 and is normal",
},
],
}),
);
const result = await runInboundExtraction({
provider,
providerType: "mock",
ocrText,
});
const { provenance } = result.facts[0]!;
expect(provenance.anchored).toBe(false);
expect(provenance.sourceText).toBe("");
expect(provenance.sourceOffset).toBeNull();
// The fact itself still stages — only its provenance claim is withheld.
expect(result.facts).toHaveLength(1);
});
it("marks vision-mode facts unanchored — there is no extracted text to verify", async () => {
const provider = fakeProvider(
JSON.stringify({
facts: [
{
type: "CONDITION",
label: "Type 2 diabetes mellitus",
confidence: 0.9,
sourceText: "Dx: Type 2 diabetes mellitus",
},
],
}),
);
const result = await runInboundExtraction({
provider,
providerType: "mock",
});
const { provenance } = result.facts[0]!;
expect(provenance.anchored).toBe(false);
expect(provenance.sourceText).toBe("");
});
it("throws InboundExtractError on unparseable provider output", async () => {
const provider = fakeProvider("not json at all");
await expect(
runInboundExtraction({ provider, providerType: "mock" }),
).rejects.toBeInstanceOf(InboundExtractError);
});
});