import type { CoverageReview } from "./coverage"; import { z } from "zod"; import type { Draft, Fact, Review, Scenario, Source } from "./types"; import { conflicts } from "./review"; export const WORKSPACE_KEY = "marmar-context-workspace-v1"; const sourceSchema = z.object({ id: z .string() .regex(/^[a-z0-9-]+$/) .max(60), title: z.string().min(1).max(120), kind: z.string().max(60), date: z.string().max(30), text: z.string().min(1).max(12000), clarification: z .object({ sourceId: z.string(), line: z.number().int().positive(), quote: z.string(), reason: z.string(), confirmedBy: z.string(), }) .optional(), }); const factSchema = z.object({ id: z.string(), kind: z.enum([ "patient", "age", "medicine", "allergy", "condition", "lab", "weight", "sex", "pregnancy", ]), label: z.string(), detail: z.string(), sourceId: z.string(), quote: z.string(), normalizedLine: z.string().optional(), line: z.number().int().positive(), key: z.string(), problem: z.string().nullable(), }); const draftSchema = z .object({ id: z.string().optional(), facts: z.array(factSchema), unparsed: z.array( z.object({ sourceId: z.string(), line: z.number(), text: z.string() }) ), mode: z.enum(["local", "model"]), model: z.string().nullable(), durationMs: z.number(), fingerprint: z.string(), sourceReviewRequired: z.boolean().optional(), warnings: z .array( z.object({ sourceId: z.string(), line: z.number(), reason: z.string(), normalizedLine: z.string().optional(), blocked: z.boolean().optional(), }) ) .optional(), }) .passthrough(); const workspaceSchema = z.object({ version: z.literal(1), savedAt: z.string(), scenario: z.object({ id: z.string(), name: z.string(), initials: z.string(), subtitle: z.string(), description: z.string(), focus: z.string(), sources: z.array(sourceSchema).min(1).max(8), }), sources: z.array(sourceSchema).min(1).max(8), sourceId: z.string(), draft: draftSchema.nullable(), review: z.record(z.string(), z.enum(["include", "exclude"])), coverageReview: z.record(z.string(), z.object({fingerprint: z.string(), reason: z.string().max(500), confirmedBy: z.string().max(120)})).optional(), useModel: z.boolean(), editing: z.boolean(), editText: z.string().max(12000), report: z.record(z.string(), z.unknown()).nullable(), }); export type Workspace = { version: 1; savedAt: string; scenario: Scenario; sources: Source[]; sourceId: string; draft: Draft | null; review: Review; coverageReview?: CoverageReview; useModel: boolean; editing: boolean; editText: string; report: Record | null; }; export function readWorkspace( storage: Pick ): Workspace | null { const raw = storage.getItem(WORKSPACE_KEY); if (!raw) return null; return parseWorkspace(raw); } export function parseWorkspace(raw: string): Workspace { if (raw.length > 2000000) throw new Error("Workspace backup is too large."); const result = workspaceSchema.safeParse(JSON.parse(raw)); if (!result.success) throw new Error( "Saved workspace could not be read. The saved copy has not been overwritten." ); return result.data as Workspace; } export function writeWorkspace( storage: Pick, workspace: Workspace ) { storage.setItem(WORKSPACE_KEY, JSON.stringify(workspace)); } function identity(f: Fact) { return JSON.stringify([ f.sourceId, f.line, f.quote, f.kind, f.key, f.label, f.detail, f.normalizedLine ?? null, f.problem, ]); } // Never transfer a decision to a changed, ambiguous or newly extracted fact. export function recoverReview( previous: Draft | null, next: Draft, review: Review ): Review { if (!previous || previous.fingerprint !== next.fingerprint) return {}; const decisions = new Map>(); for (const fact of previous.facts) if (review[fact.id]) { const key = identity(fact); const values = decisions.get(key) ?? new Set(); values.add(review[fact.id]); decisions.set(key, values); } return Object.fromEntries( next.facts.flatMap((f) => { const values = decisions.get(identity(f)); return values?.size === 1 ? [[f.id, [...values][0]]] : []; }) ) as Review; } export function linkedClarification( sources: Source[], sourceId: string, line: number ) { return sources.find( (s) => s.clarification?.sourceId === sourceId && s.clarification.line === line ); } export function clarificationReviewed( sources: Source[], draft: Draft | null, review: Review, sourceId: string, line: number ) { const linked = linkedClarification(sources, sourceId, line); if (!linked || !draft) return false; const facts = draft.facts.filter((f) => f.sourceId === linked.id); const included = draft.facts.filter((f) => review[f.id] === "include"); const conflictKeys = conflicts(included); return ( facts.some( (f) => f.kind !== "patient" && f.kind !== "age" && !f.problem && review[f.id] === "include" ) && facts.every( (f) => !!review[f.id] && !( review[f.id] === "include" && (f.problem || conflictKeys.includes(f.key)) ) ) && !draft.unparsed.some((l) => l.sourceId === linked.id) && !draft.warnings?.some((w) => w.sourceId === linked.id) && !draft.facts.some( (f) => f.sourceId === sourceId && f.line === line && f.problem && review[f.id] !== "exclude" ) ); } export function actionableWarnings(draft: Draft | null) { return (draft?.warnings ?? []).filter( (w) => !( w.reason === "A partial name cannot replace the patient identity already recorded in this source." && draft?.facts.some( (f) => f.sourceId === w.sourceId && f.kind === "patient" && !f.problem ) ) ); } export function clarificationError(text: string, basis: string) { if (!text.trim()) return "Enter the confirmed patient statement."; if (text.trim().length > 12000) return "The confirmed statement must be 12,000 characters or fewer."; if (!basis.trim()) return "Enter who confirmed this detail or the verification basis."; if (basis.trim().length > 120) return "Confirmed by / basis must be 120 characters or fewer. Shorten it before saving."; return ""; }