mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Opening a document said "the summary is being prepared" and kept saying it. The view had one fallback for a null summary column and used it for every outcome: a document nobody ever enqueued, a missing vision provider, a spent budget, a withdrawn consent, an unreadable file, a provider error, and a summary the outbound screen withheld all rendered as work in progress. The common case is not the screen. The background job is enqueued at exactly one place — a fresh upload with the auto-read opt-in already on — and no backfill reaches anything else. Every document uploaded before the toggle went on has a null column no job will ever fill, and the view promised all of them a summary that was never coming. Record the outcome as `summary_state` (migration 0254, additive, defaults to NONE which is truthful for every existing row) and branch on it. Only PENDING, set when a job genuinely exists, may claim generation is running. NONE, WITHHELD and UNAVAILABLE each say what happened and offer to generate one. The withhold keeps its rule and loses its dead end. Blocked prose is still never shown and never stored — only the fact of the refusal is kept, and it is no longer terminal, so asking again re-runs the gauntlet. That was the original intent; leaving the column null defeated it, because null read as "generating" and the guard meant nothing would ever overwrite it. The on-demand summary now persists what it produces. It is the only repair path for the documents the job never ran for, and re-deriving the same paragraph on every open costs a provider call for nothing. Generation is still only ever a click — nothing runs because a view rendered. Transcription stays session-only. Also drop the "read by AI" line from the detail footer.
763 lines
28 KiB
TypeScript
763 lines
28 KiB
TypeScript
/**
|
|
* v1.25 (W-DOCS-IN) — inbound clinical-document ingestion contract.
|
|
*
|
|
* A self-hoster uploads a doctor report / discharge letter they received. The
|
|
* dedicated OCR/vision provider transcribes STRUCTURED FACTS that land on a
|
|
* MANDATORY review-then-confirm screen; only the user-approved facts reach the
|
|
* structured stores (labs / conditions / medications) through their normal
|
|
* create paths.
|
|
*
|
|
* The safety line this module encodes: EXTRACT, NEVER INTERPRET. The provider
|
|
* is asked to reproduce what is written (text, value, code, date) with
|
|
* provenance and a confidence gate — it never infers a code, never flags a
|
|
* value high/low, never links a condition to a medication, never assigns
|
|
* meaning, severity, or a diagnosis. Diagnoses are stored as the document's
|
|
* 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 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).
|
|
* - `inboundConfirmSchema` — the approve/reject decisions. No `userId` field;
|
|
* it is always narrowed from the session.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
|
|
/** Max facts a single document may stage / confirm. A dense letter is ~40. */
|
|
export const INBOUND_MAX_FACTS = 120;
|
|
|
|
/**
|
|
* Confidence floor. A fact scoring below this fails closed: it is staged with
|
|
* `needsReview = true` and cannot be approved until the user edits it (the
|
|
* values then become user-asserted). When unsure, the app surfaces raw text
|
|
* for manual entry rather than guessing a value.
|
|
*/
|
|
export const INBOUND_CONFIDENCE_FLOOR = 0.6;
|
|
|
|
/** The three FHIR resources a fact maps to (stated status only). */
|
|
export const INBOUND_FACT_TYPES = [
|
|
"CONDITION",
|
|
"OBSERVATION",
|
|
"MEDICATION_STATEMENT",
|
|
] as const;
|
|
export type InboundFactType = (typeof INBOUND_FACT_TYPES)[number];
|
|
|
|
/** The document-kind labels (no interpretation — a label only). */
|
|
export const INBOUND_DOCUMENT_KINDS = [
|
|
"DOCTOR_REPORT",
|
|
"DISCHARGE_LETTER",
|
|
"LAB_RESULT",
|
|
"IMAGING",
|
|
"PRESCRIPTION",
|
|
"REFERRAL",
|
|
"INSURANCE",
|
|
"VACCINATION",
|
|
"OTHER",
|
|
] as const;
|
|
export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number];
|
|
|
|
/**
|
|
* How a document's content index was produced (mirrors the server `source`
|
|
* column). `vision` means an AI provider read the original (the AI-first path);
|
|
* every other value is a provider-free local extraction. The UI reads it to
|
|
* tell an AI-read document from a locally-indexed one — the former already had
|
|
* the richer read, the latter can still be offered "Read with AI".
|
|
*/
|
|
export const DOCUMENT_CONTENT_INDEX_SOURCES = [
|
|
"vision",
|
|
"text-ocr",
|
|
"local-pdf",
|
|
"local-ocr",
|
|
] as const;
|
|
export type DocumentContentIndexSourceValue =
|
|
(typeof DOCUMENT_CONTENT_INDEX_SOURCES)[number];
|
|
|
|
/** True when the index was produced by an AI provider reading the original. */
|
|
export function isAiReadSource(
|
|
source: DocumentContentIndexSourceValue | string | null,
|
|
): boolean {
|
|
return source === "vision";
|
|
}
|
|
|
|
/**
|
|
* Narrow the free-String DB `source` column to the DTO union, or `null` for an
|
|
* absent / unrecognised value. The column is a free String server-side (adding
|
|
* a source needs no migration) so the read path guards it before it hits the
|
|
* contract.
|
|
*/
|
|
export function toContentIndexSource(
|
|
source: string | null | undefined,
|
|
): DocumentContentIndexSourceValue | null {
|
|
return DOCUMENT_CONTENT_INDEX_SOURCES.includes(
|
|
source as DocumentContentIndexSourceValue,
|
|
)
|
|
? (source as DocumentContentIndexSourceValue)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* What became of a document's plain-language summary. Mirrors the Prisma
|
|
* `DocumentSummaryState` enum. The detail view branches on this rather than on
|
|
* a null summary, which cannot tell "never attempted" from "in flight" from
|
|
* "withheld" from "could not produce".
|
|
*/
|
|
export const DOCUMENT_SUMMARY_STATES = [
|
|
"NONE",
|
|
"PENDING",
|
|
"READY",
|
|
"WITHHELD",
|
|
"UNAVAILABLE",
|
|
] as const;
|
|
export type DocumentSummaryStateValue =
|
|
(typeof DOCUMENT_SUMMARY_STATES)[number];
|
|
|
|
/** The document lifecycle states. STORED is the library default. */
|
|
export const INBOUND_DOCUMENT_STATUSES = [
|
|
"STORED",
|
|
"EXTRACTING",
|
|
"EXTRACTED",
|
|
"FAILED",
|
|
"CONFIRMED",
|
|
"DISCARDED",
|
|
] as const;
|
|
export type InboundDocumentStatusValue =
|
|
(typeof INBOUND_DOCUMENT_STATUSES)[number];
|
|
|
|
/**
|
|
* The coding systems a fact's STATED code may belong to. The provider emits a
|
|
* code ONLY when the document writes one — it never machine-guesses. Mirrors
|
|
* the systems the existing FHIR mappers already speak (SNOMED/ICD-10 for a
|
|
* Condition, LOINC for an Observation, RxNorm/ATC for a MedicationStatement).
|
|
*/
|
|
export const INBOUND_CODE_SYSTEMS = [
|
|
"SNOMED",
|
|
"ICD10",
|
|
"LOINC",
|
|
"RXNORM",
|
|
"ATC",
|
|
] 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.
|
|
*/
|
|
export const extractedFactRawSchema = 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),
|
|
/** 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),
|
|
});
|
|
|
|
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. */
|
|
export interface FactProvenance {
|
|
/** The verbatim source span the value was transcribed from. */
|
|
sourceText: string;
|
|
/** Optional 0-based page index. */
|
|
page: number | null;
|
|
/** The model's self-reported confidence (0..1). */
|
|
confidence: number;
|
|
}
|
|
|
|
/** Condition staging payload — maps to FHIR `Condition` (stated status). */
|
|
export interface ConditionFactData {
|
|
label: string;
|
|
code: string | null;
|
|
codeSystem: InboundCodeSystem | null;
|
|
clinicalStatus: string | null;
|
|
verificationStatus: string | null;
|
|
onsetDate: string | null;
|
|
}
|
|
|
|
/** Observation staging payload — maps to FHIR `Observation` (no range-flag). */
|
|
export interface ObservationFactData {
|
|
label: string;
|
|
code: string | null;
|
|
codeSystem: InboundCodeSystem | null;
|
|
value: number | null;
|
|
valueText: string | null;
|
|
unit: string | null;
|
|
referenceLow: number | null;
|
|
referenceHigh: number | null;
|
|
effectiveDate: string | null;
|
|
}
|
|
|
|
/** MedicationStatement staging payload — maps to FHIR `MedicationStatement`. */
|
|
export interface MedicationStatementFactData {
|
|
name: string;
|
|
dose: string | null;
|
|
rxNormCode: string | null;
|
|
atcCode: string | null;
|
|
statusStated: string | null;
|
|
effectiveDate: string | null;
|
|
}
|
|
|
|
export type FactData =
|
|
ConditionFactData | ObservationFactData | MedicationStatementFactData;
|
|
|
|
/** The staged-fact DTO the routes return + the review UI consumes. */
|
|
export interface ExtractedFactDto {
|
|
id: string;
|
|
factType: InboundFactType;
|
|
status: "PENDING" | "APPROVED" | "REJECTED";
|
|
confidence: number;
|
|
needsReview: boolean;
|
|
data: FactData;
|
|
provenance: FactProvenance;
|
|
committedRecordId: string | null;
|
|
committedRecordType: string | null;
|
|
}
|
|
|
|
/** One condition link on a document DTO (chip → `/illness/[id]`). */
|
|
export interface DocumentConditionLinkDto {
|
|
episodeId: string;
|
|
/** The episode's user-facing label. */
|
|
name: string;
|
|
}
|
|
|
|
/** The document DTO (list + detail). */
|
|
export interface InboundDocumentDto {
|
|
id: string;
|
|
kind: InboundDocumentKindValue;
|
|
/** User-given title (plaintext), or null when never set. */
|
|
title: string | null;
|
|
filename: string | null;
|
|
mimeType: string;
|
|
byteSize: number;
|
|
status: InboundDocumentStatusValue;
|
|
providerType: string | null;
|
|
/** Model-transcribed report/collection date (YYYY-MM-DD), or null. */
|
|
reportDate: string | null;
|
|
/** User-set filing date (YYYY-MM-DD), or null. */
|
|
documentDate: string | null;
|
|
errorReason: string | null;
|
|
/** Count of the document's non-REJECTED staged facts. */
|
|
factCount: number;
|
|
/** Count of staged facts still PENDING review. */
|
|
pendingCount: number;
|
|
/** Linked illness/condition episodes (empty when unlinked). */
|
|
conditionLinks: DocumentConditionLinkDto[];
|
|
/** How the serve route delivers the original: render inline or download. */
|
|
servingClass: "inline" | "attachment";
|
|
/**
|
|
* Whether the document has a content-search index (encrypted extracted text +
|
|
* blind token array). `false` until the document is indexed (auto-indexed on
|
|
* upload; the UI reads this for the searchable status, not as a to-do).
|
|
*/
|
|
hasContentIndex: boolean;
|
|
/**
|
|
* How that index was produced — `vision` (an AI provider read the original)
|
|
* vs a local extraction (`local-pdf` / `text-ocr` / `local-ocr`), or `null`
|
|
* when `hasContentIndex` is false. Lets the UI tell an AI-read document from
|
|
* a locally-indexed one and offer a richer "Read with AI" pass on the latter.
|
|
*/
|
|
contentIndexSource: DocumentContentIndexSourceValue | null;
|
|
/**
|
|
* Whether the document has an encrypted preview thumbnail (rendered in the
|
|
* background after upload). Gates the card's `<img>`: when false the card
|
|
* shows its kind icon instead of fetching a thumbnail that 404s.
|
|
*/
|
|
hasThumbnail: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface InboundDocumentDetailDto extends InboundDocumentDto {
|
|
facts: ExtractedFactDto[];
|
|
/**
|
|
* Short (3-4 sentence) plain-language summary of WHAT the document is,
|
|
* generated once in the background after upload when the `documentsAutoAiRead`
|
|
* opt-in is ON. Null when auto-read is OFF, no provider is configured, or the
|
|
* background summary has not run yet. Descriptive only — never a diagnosis.
|
|
*/
|
|
summary: string | null;
|
|
/** When the background summary was generated (ISO 8601), or null. */
|
|
summaryGeneratedAt: string | null;
|
|
/**
|
|
* What became of the summary. A null `summary` is ambiguous on its own —
|
|
* never attempted, mid-flight, withheld by the safety screen and could-not-
|
|
* produce all look identical — so the view reads this instead of guessing
|
|
* "still generating".
|
|
*/
|
|
summaryState: DocumentSummaryStateValue;
|
|
}
|
|
|
|
// ─── Edit (correction before approval) ─────────────────────────────────────
|
|
|
|
const reqText = (max: number) => z.string().trim().min(1).max(max);
|
|
const optText = (max: number) =>
|
|
z
|
|
.string()
|
|
.trim()
|
|
.max(max)
|
|
.nullable()
|
|
.optional()
|
|
.transform((v) => (v === "" ? null : v));
|
|
const optCode = z.enum(INBOUND_CODE_SYSTEMS).nullable().optional();
|
|
const isoDate = z
|
|
.string()
|
|
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected a YYYY-MM-DD date")
|
|
.nullable()
|
|
.optional();
|
|
|
|
const conditionEditSchema = z.object({
|
|
factType: z.literal("CONDITION"),
|
|
label: reqText(300),
|
|
code: optText(64),
|
|
codeSystem: optCode,
|
|
clinicalStatus: optText(64),
|
|
verificationStatus: optText(64),
|
|
onsetDate: isoDate,
|
|
});
|
|
|
|
const observationEditSchema = z
|
|
.object({
|
|
factType: z.literal("OBSERVATION"),
|
|
label: reqText(300),
|
|
code: optText(64),
|
|
codeSystem: optCode,
|
|
value: z.number().finite().nullable().optional(),
|
|
valueText: optText(300),
|
|
unit: optText(80),
|
|
referenceLow: z.number().finite().nullable().optional(),
|
|
referenceHigh: z.number().finite().nullable().optional(),
|
|
effectiveDate: isoDate,
|
|
})
|
|
// Numeric XOR qualitative, mirroring the lab-result discipline. A null/absent
|
|
// value with a null/absent valueText is allowed (the user may still be
|
|
// entering it) but not BOTH set.
|
|
.refine(
|
|
(d) => !(typeof d.value === "number" && typeof d.valueText === "string"),
|
|
{
|
|
message: "Provide a numeric value OR qualitative text, not both",
|
|
path: ["value"],
|
|
},
|
|
);
|
|
|
|
const medicationEditSchema = z.object({
|
|
factType: z.literal("MEDICATION_STATEMENT"),
|
|
name: reqText(200),
|
|
dose: optText(120),
|
|
rxNormCode: optText(20),
|
|
atcCode: optText(16),
|
|
statusStated: optText(64),
|
|
effectiveDate: isoDate,
|
|
});
|
|
|
|
/**
|
|
* A correction to a staged fact (the review screen edits OCR / units / dates /
|
|
* codes before approval). Discriminated by `factType` so the edit can never
|
|
* change a fact's resource type. A successful edit clears `needsReview` — the
|
|
* values become user-asserted.
|
|
*/
|
|
export const inboundFactEditSchema = z.discriminatedUnion("factType", [
|
|
conditionEditSchema,
|
|
observationEditSchema,
|
|
medicationEditSchema,
|
|
]);
|
|
|
|
export type InboundFactEdit = z.infer<typeof inboundFactEditSchema>;
|
|
|
|
// ─── Confirm (approve / reject) ────────────────────────────────────────────
|
|
|
|
/**
|
|
* The approve/reject decisions the user made on the review screen. Each
|
|
* decision names a staged fact by id. Approved facts are committed to the
|
|
* structured stores; rejected facts are discarded. No `userId` field — it is
|
|
* always narrowed from the session; the route also re-scopes every fact id to
|
|
* the document + the caller.
|
|
*/
|
|
export const inboundConfirmSchema = z.object({
|
|
decisions: z
|
|
.array(
|
|
z.object({
|
|
factId: z.string().trim().min(1).max(40),
|
|
action: z.enum(["approve", "reject"]),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(INBOUND_MAX_FACTS),
|
|
});
|
|
|
|
export type InboundConfirmInput = z.infer<typeof inboundConfirmSchema>;
|
|
|
|
/** The text-mode (local-OCR) extract body, mirroring the Lab-OCR text mode. */
|
|
export const INBOUND_TEXT_MAX_CHARS = 200_000;
|
|
|
|
export const inboundTextExtractSchema = z.object({
|
|
mode: z.literal("text"),
|
|
text: z.string().trim().min(1).max(INBOUND_TEXT_MAX_CHARS),
|
|
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
|
});
|
|
|
|
export type InboundTextExtractInput = z.infer<typeof inboundTextExtractSchema>;
|
|
|
|
// ─── Library: store / edit / list ──────────────────────────────────────────
|
|
|
|
/** A bare YYYY-MM-DD date string (no time component). */
|
|
const isoDateString = z
|
|
.string()
|
|
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected a YYYY-MM-DD date");
|
|
|
|
export const DOCUMENT_TITLE_MAX = 200;
|
|
|
|
/** Max condition links a single document may carry / receive per request. */
|
|
export const DOCUMENT_MAX_EPISODE_LINKS = 20;
|
|
|
|
const episodeIdList = z
|
|
.array(z.string().trim().min(1).max(40))
|
|
.max(DOCUMENT_MAX_EPISODE_LINKS);
|
|
|
|
/**
|
|
* The store-only upload metadata (the multipart form fields beside the file).
|
|
* Every field is optional — a bare file upload is valid and lands as a STORED
|
|
* document with no title / category / filing date. `episodeIds` pre-links the
|
|
* document to the caller's illness/condition episodes (repeated `episodeIds`
|
|
* form fields; ownership is re-checked in the route). No `userId` field; it
|
|
* is always narrowed from the session.
|
|
*/
|
|
export const documentCreateSchema = z.object({
|
|
title: z.string().trim().min(1).max(DOCUMENT_TITLE_MAX).optional(),
|
|
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
|
documentDate: isoDateString.optional(),
|
|
episodeIds: episodeIdList.optional(),
|
|
});
|
|
|
|
export type DocumentCreateInput = z.infer<typeof documentCreateSchema>;
|
|
|
|
/**
|
|
* Metadata edit (rename / recategorise / set the filing date). At least one
|
|
* field must be present. `title` accepts null to clear it; `documentDate`
|
|
* accepts null to clear it. No `userId` field — narrowed from the session and
|
|
* fed to the Prisma `where` alongside the row id.
|
|
*/
|
|
export const documentUpdateSchema = z
|
|
.object({
|
|
title: z
|
|
.string()
|
|
.trim()
|
|
.max(DOCUMENT_TITLE_MAX)
|
|
.nullable()
|
|
.optional()
|
|
.transform((v) => (v === "" ? null : v)),
|
|
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
|
documentDate: isoDateString.nullable().optional(),
|
|
/**
|
|
* Replace-set condition links: the document's links become exactly this
|
|
* set (an empty array unlinks everything). Ownership of every episode id
|
|
* is re-checked in the route against the caller's episodes.
|
|
*/
|
|
episodeIds: episodeIdList.optional(),
|
|
})
|
|
.refine(
|
|
(d) =>
|
|
d.title !== undefined ||
|
|
d.kind !== undefined ||
|
|
d.documentDate !== undefined ||
|
|
d.episodeIds !== undefined,
|
|
{ message: "Provide at least one field to update" },
|
|
);
|
|
|
|
export type DocumentUpdateInput = z.infer<typeof documentUpdateSchema>;
|
|
|
|
/** Library list sort columns + page size. */
|
|
export const DOCUMENT_LIST_SORTS = [
|
|
"documentDate",
|
|
"createdAt",
|
|
"title",
|
|
] as const;
|
|
export type DocumentListSort = (typeof DOCUMENT_LIST_SORTS)[number];
|
|
|
|
export const DOCUMENT_LIST_MAX_LIMIT = 100;
|
|
export const DOCUMENT_LIST_DEFAULT_LIMIT = 50;
|
|
|
|
/**
|
|
* The library list query: title/filename search, category filter, a
|
|
* `documentDate` range, sort + keyset pagination. Parsed off `searchParams`;
|
|
* `safeParse` returns 422 on a bad value.
|
|
*/
|
|
export const documentListQuerySchema = z.object({
|
|
q: z.string().trim().max(100).optional(),
|
|
/**
|
|
* Category filter — OR inside the facet. Repeated `kind` params or a
|
|
* comma-separated value; the route normalises to an array before parsing.
|
|
*/
|
|
kind: z.array(z.enum(INBOUND_DOCUMENT_KINDS)).max(16).optional(),
|
|
/** Only documents linked to this illness/condition episode. */
|
|
episodeId: z.string().trim().min(1).max(40).optional(),
|
|
/** Only documents whose filing date falls in this calendar year (UTC). */
|
|
year: z.coerce.number().int().min(1900).max(9999).optional(),
|
|
from: isoDateString.optional(),
|
|
to: isoDateString.optional(),
|
|
sort: z.enum(DOCUMENT_LIST_SORTS).default("documentDate"),
|
|
order: z.enum(["asc", "desc"]).default("desc"),
|
|
cursor: z.string().trim().min(1).max(40).optional(),
|
|
limit: z.coerce
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.max(DOCUMENT_LIST_MAX_LIMIT)
|
|
.default(DOCUMENT_LIST_DEFAULT_LIMIT),
|
|
});
|
|
|
|
export type DocumentListQuery = z.infer<typeof documentListQuerySchema>;
|
|
|
|
// ─── Vault: bulk actions ───────────────────────────────────────────────────
|
|
|
|
/** Max document ids one bulk request may touch. */
|
|
export const DOCUMENT_BULK_MAX_IDS = 100;
|
|
|
|
export const DOCUMENT_BULK_ACTIONS = [
|
|
"setKind",
|
|
"linkEpisode",
|
|
"unlinkEpisode",
|
|
"delete",
|
|
"restore",
|
|
] as const;
|
|
export type DocumentBulkAction = (typeof DOCUMENT_BULK_ACTIONS)[number];
|
|
|
|
/**
|
|
* One bulk action over up to 100 owner-scoped documents. `setKind` requires
|
|
* `kind`; `linkEpisode` / `unlinkEpisode` require `episodeId` (ownership
|
|
* re-checked in the route). Per-id outcomes ride the response so a partial
|
|
* failure never aborts the batch. No `userId` field — narrowed from the
|
|
* session.
|
|
*/
|
|
export const documentBulkSchema = z
|
|
.object({
|
|
ids: z
|
|
.array(z.string().trim().min(1).max(40))
|
|
.min(1)
|
|
.max(DOCUMENT_BULK_MAX_IDS),
|
|
action: z.enum(DOCUMENT_BULK_ACTIONS),
|
|
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
|
episodeId: z.string().trim().min(1).max(40).optional(),
|
|
})
|
|
.refine((d) => d.action !== "setKind" || d.kind !== undefined, {
|
|
message: "Action 'setKind' requires a kind",
|
|
path: ["kind"],
|
|
})
|
|
.refine(
|
|
(d) =>
|
|
(d.action !== "linkEpisode" && d.action !== "unlinkEpisode") ||
|
|
d.episodeId !== undefined,
|
|
{
|
|
message: "Episode actions require an episodeId",
|
|
path: ["episodeId"],
|
|
},
|
|
);
|
|
|
|
export type DocumentBulkInput = z.infer<typeof documentBulkSchema>;
|
|
|
|
/** Per-id outcome in the bulk response. */
|
|
export interface DocumentBulkResultDto {
|
|
id: string;
|
|
ok: boolean;
|
|
/** Short machine reason when `ok` is false (e.g. "notFound", "conflict"). */
|
|
error: string | null;
|
|
}
|
|
|
|
/** The usage endpoint payload the UI reads before offering an upload. */
|
|
export interface DocumentUsageDto {
|
|
usedBytes: number;
|
|
quotaBytes: number;
|
|
maxFileBytes: number;
|
|
acceptedExtensions: string[];
|
|
/**
|
|
* Episodes carrying at least one LIVE document link — the vault filter
|
|
* bar's condition chips. Sourced server-side so a chip exists even when
|
|
* every linked document sits pages deep in the timeline.
|
|
*/
|
|
linkedEpisodes: DocumentConditionLinkDto[];
|
|
/**
|
|
* Whether the AI "Suggest details" action can run for this caller — true when
|
|
* a provider (vision, or text + local OCR) is configured. The UI hides the
|
|
* action when false so it never offers what the endpoint would 422.
|
|
*/
|
|
assistAvailable: boolean;
|
|
/**
|
|
* Content-search index state: whether indexing is available to the caller
|
|
* (`enabled`, same provider precondition as assist), how many live documents
|
|
* are indexed, and the live-document total — so the UI can gauge coverage and
|
|
* offer the "index all documents" backfill.
|
|
*/
|
|
contentIndex: {
|
|
enabled: boolean;
|
|
indexedCount: number;
|
|
totalCount: number;
|
|
};
|
|
}
|
|
|
|
/** Where a document read egresses, vendor-blind. */
|
|
export type DocumentEgressClass = "local" | "external";
|
|
|
|
/**
|
|
* The document-scoped AI capability probe response
|
|
* (`GET /api/documents/inbound/capability`). Resolved over the DOCUMENT
|
|
* provider order (local-first, codex last), so `mode` / `pdfSupported` /
|
|
* `egress` match exactly what the document AI routes will do. `egress` drives
|
|
* the vault's per-egress "this leaves your machine to a third-party AI" notice.
|
|
*/
|
|
export interface DocumentAiCapabilityDto {
|
|
available: boolean;
|
|
mode: "vision" | "text" | null;
|
|
reason: "no-provider" | "enable-local-ocr" | null;
|
|
pdfSupported: boolean;
|
|
/**
|
|
* Where a document read will egress with the current provider order:
|
|
* - "local": stays on the operator's machine (self-hosted model).
|
|
* - "external": leaves the machine to a third-party AI service.
|
|
* - null: no read is available (see `reason`).
|
|
*/
|
|
egress: DocumentEgressClass | null;
|
|
}
|
|
|
|
// ─── AI assist / summary / index (Document vault P2) ────────────────────────
|
|
|
|
/**
|
|
* The filing-metadata suggestion the assist endpoint returns. Drafts ONLY — the
|
|
* client prefills the edit form and the user saves; nothing is written by the
|
|
* suggest call. Every field is nullable (the model leaves it null when it
|
|
* cannot read it confidently).
|
|
*/
|
|
export interface DocumentSuggestionDto {
|
|
title: string | null;
|
|
kind: InboundDocumentKindValue | null;
|
|
documentDate: string | null;
|
|
}
|
|
|
|
/** The session-only summary/extracted-text mode the summary route serves. */
|
|
export const DOCUMENT_SUMMARY_MODES = ["summary", "text"] as const;
|
|
export type DocumentSummaryMode = (typeof DOCUMENT_SUMMARY_MODES)[number];
|
|
|
|
// ─── Chat about a document (Document vault P4) ──────────────────────────────
|
|
|
|
/** Max characters of a single user turn in a document chat. */
|
|
export const DOCUMENT_CHAT_MESSAGE_MAX = 4000;
|
|
|
|
/**
|
|
* Request body for `POST /api/documents/inbound/{id}/chat`. No `userId` and no
|
|
* `documentId` field — the user is narrowed from the session and the document id
|
|
* comes from the path. `conversationId` continues an existing thread scoped to
|
|
* this document; omitting it starts a new one. `locale` is the reply language
|
|
* (the document chat, like the Coach, replies in de/en).
|
|
*/
|
|
export const documentChatRequestSchema = z
|
|
.object({
|
|
conversationId: z.string().trim().min(1).max(64).optional(),
|
|
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
|
|
locale: z.enum(["en", "de"]).optional(),
|
|
})
|
|
.meta({ id: "DocumentChatRequest" });
|
|
|
|
export type DocumentChatRequestInput = z.infer<
|
|
typeof documentChatRequestSchema
|
|
>;
|
|
|
|
/**
|
|
* Query for `GET /api/documents/inbound/{id}/chat`. With `conversationId`, the
|
|
* endpoint returns that one thread's messages (owner + document scoped); without
|
|
* it, the paginated list of the document's chat threads for the sheet's rail.
|
|
*/
|
|
export const documentChatHistoryQuerySchema = z.object({
|
|
conversationId: z.string().trim().min(1).max(64).optional(),
|
|
cursor: z.string().trim().min(1).max(64).optional(),
|
|
limit: z.coerce.number().int().min(1).max(50).optional(),
|
|
});
|
|
|
|
export type DocumentChatHistoryQuery = z.infer<
|
|
typeof documentChatHistoryQuerySchema
|
|
>;
|
|
|
|
// ─── Fenced multi-document coach chat (S7) ──────────────────────────────────
|
|
|
|
/** Max documents attachable to one coach conversation. Bounds prompt size + the
|
|
* numeric-grounding union; five labelled documents is already past the point a
|
|
* chat answer stays useful. */
|
|
export const MAX_COACH_ATTACHMENTS = 5;
|
|
|
|
/**
|
|
* Request body for `POST /api/insights/chat/fenced`. `.strict()` — the schema
|
|
* REFUSES the tool-mode fields (`scope` / `guidedQuestion` / `prefill`) and
|
|
* `userId` rather than silently dropping them, so a confused client fails loudly
|
|
* in test. `conversationId` continues an existing FENCED thread; `attachmentIds`
|
|
* (first-turn only, min 1) creates a fresh fenced thread — supplying BOTH is a
|
|
* 422 (attach-to-existing goes through the attach endpoint; one write path per
|
|
* concern). No `documentId` / `userId` field — the owner is narrowed from the
|
|
* session, the documents come from the join table.
|
|
*/
|
|
export const fencedChatRequestSchema = z
|
|
.object({
|
|
conversationId: z.string().trim().min(1).max(64).optional(),
|
|
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
|
|
locale: z.enum(["en", "de"]).optional(),
|
|
attachmentIds: z
|
|
.array(z.string().trim().min(1).max(64))
|
|
.min(1)
|
|
.max(MAX_COACH_ATTACHMENTS)
|
|
.optional(),
|
|
})
|
|
.strict()
|
|
.meta({ id: "FencedCoachChatRequest" });
|
|
|
|
export type FencedChatRequestInput = z.infer<typeof fencedChatRequestSchema>;
|
|
|
|
/**
|
|
* Request body for `POST /api/insights/chat/{id}/attachments` — attach ONE
|
|
* already-stored document to an existing conversation. `.strict()`; no `userId`.
|
|
*/
|
|
export const coachAttachmentCreateSchema = z
|
|
.object({
|
|
documentId: z.string().trim().min(1).max(64),
|
|
})
|
|
.strict()
|
|
.meta({ id: "CoachAttachmentCreateRequest" });
|
|
|
|
export type CoachAttachmentCreateInput = z.infer<
|
|
typeof coachAttachmentCreateSchema
|
|
>;
|