Files
HealthLog/src/lib/documents/fenced-chat.ts
T
Marc-André Bombeck bd8ea9d86b feat(ai): gate document, lab and medication extraction on their capabilities
Reading a stored document (suggest, summary, extract, index, chat,
reindex), scanning a lab report and turning medication text into a
schedule now answer under documentAi, labsOcr and medicationExtract. The
operator's master switch and the new reading-documents switch stop the
work and the egress; a module off for the record, a delegate inside
somebody else's record, and a missing receipt refuse with the capability
envelope. Existing no-provider codes keep their status.

The document pick and the labs pick re-check the capability for the
provider they picked, immediately before anything is sent, so a job
enqueued before a switch flipped finds no provider and falls back to the
provider-free text layer. Consent follows the document rule for all
three: any provider that leaves the machine needs an ai_extraction or
ai_full receipt, answered for the provider actually picked.

The fenced Coach chat and attachments answer under coach and documentAi.
Uploads are always stored and indexed; the background summary is only
enqueued while documentAi is open. The capability probes report the
capability beside the provider answer and keep a read offered when only
the receipt is missing. medications/extract keeps its 503 for no provider
and adds ai.provider.none, and its budget refusal gains the
coach.budget.exceeded code.
2026-09-24 08:44:57 +02:00

590 lines
20 KiB
TypeScript

/**
* v1.29.x (S7) — the shared FENCED-CHAT turn pipeline.
*
* Extraction of the v1.27.33 single-document chat route body, generalised over N
* attached documents. Both fenced entry points share this module so they cannot
* drift:
* - `POST /api/insights/chat/fenced` (conversation-keyed, N docs)
* - `POST /api/documents/inbound/[id]/chat` (path-keyed, 1 doc — the public
* iOS contract, unchanged wire)
*
* THE INVARIANT this module upholds (write it on the wall): document text enters
* an LLM prompt in EXACTLY ONE code path — this one. It registers NO tools and
* builds NO health snapshot. There is intentionally no import of the coach tool
* registry or snapshot builder anywhere in the fenced graph; a structural test
* (`fenced-chat-module-graph.test.ts`) pins that so a future refactor cannot
* quietly wire document text into the tool path.
*
* Security posture carried verbatim from the single-doc route:
* - NO TOOLS, NO SNAPSHOT — one completion per turn.
* - PROMPT-INJECTION FENCING — every document fenced as DATA (`fenceDocument`),
* per-doc header fields marker-scrubbed (`buildFencedChatSystemPrompt`).
* - CAPABILITY AT THE WIRE — the single picked provider (no cascade) is
* re-checked against `documentAi` (the operator's switch, the module, the
* sharing grant, and a document consent receipt for a pick that leaves the
* machine) and against every other capability the entry point names (the
* Coach, for a turn inside a Coach conversation). Any refusal is a 403
* BEFORE any egress and BEFORE the user turn is persisted.
* - NUMERIC GROUNDING over the LIVE attachment union only — recomputed per turn
* from the join rows, never history / snapshot.
* - OWNER-SCOPED TEXT LOADER as a second ownership layer — a corrupted/foreign
* join row yields null and the turn refuses (422), never foreign text.
*/
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { createSseStream } from "@/lib/sse/create-stream";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertAiEgress } from "@/lib/ai/capabilities/egress";
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
import {
AllProvidersFailedError,
runStreamingRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { singleUserTurn } from "@/lib/ai/types";
import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveCostOwner,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { detectRefusal, type CoachRefusalReason } from "@/lib/ai/coach/refusal";
import type { Locale } from "@/lib/i18n/config";
import {
coachOutboundFallback,
screenCoachReply,
} from "@/lib/ai/coach/outbound-guard";
import {
findUnverifiedCoachNumbers,
stripUnverifiedNumbers,
} from "@/lib/ai/coach/coach-prose-grounding";
import { appendMessage } from "@/lib/ai/coach/persistence";
import type { CoachStreamEvent } from "@/lib/ai/coach/types";
import { loadDocumentChatText } from "@/lib/documents/content-index";
import {
buildFencedChatSystemPrompt,
type FencedDoc,
} from "@/lib/documents/document-chat-prompt";
import { resolveDocumentTextProvider } from "@/lib/documents/provider-order";
const FENCED_SSE_HEADERS: Record<string, string> = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
};
const HEARTBEAT_MS = 12_000;
const HEARTBEAT_FRAME = new TextEncoder().encode(": ka\n\n");
/** Prior turns kept verbatim in the per-call window — fenced chats are short. */
const HISTORY_TURN_CAP = 20;
export interface FencedTurn {
role: "user" | "assistant";
content: string;
}
function encodeFencedFrame(event: CoachStreamEvent): Uint8Array {
return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`);
}
/** Split a full reply into ~word-sized chunks for a streaming feel. */
function tokeniseForStreaming(content: string): string[] {
if (!content) return [];
const matches = content.match(/\S+\s*/g);
return matches ?? [content];
}
/** Yield to the event loop so each SSE frame flushes as its own chunk. */
function flushTick(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
/** A standalone SSE stream carrying a single structured error frame (HTTP 200). */
function streamFencedError(code: string): Response {
const stream = createSseStream((controller) => {
controller.enqueue(
encodeFencedFrame({ type: "error", code, message: code }),
);
});
return new Response(stream, { status: 200, headers: FENCED_SSE_HEADERS });
}
// ─── Owner-scoped context loader (the second ownership layer) ────────────────
export interface FencedDocContext extends FencedDoc {
documentId: string;
source: "verbatim" | "normalised";
}
export type LoadFencedResult =
| { ok: true; docs: FencedDocContext[] }
| { ok: false; unavailableDocId: string };
/**
* Load every attached document's label + fenced text, OWNER-SCOPED. Each id is
* checked twice: once for liveness + label (`deletedAt: null`, owner-narrowed)
* and once through `loadDocumentChatText` (owner-scoped, indexed-only). A miss on
* EITHER — deleted, soft-deleted, foreign (corrupted join row), or un-indexed —
* fails the WHOLE turn (never a partial-context reply). Returns the failing id so
* the route names it in the 422.
*/
export async function loadFencedDocuments(
userId: string,
documentIds: string[],
): Promise<LoadFencedResult> {
const docs: FencedDocContext[] = [];
for (const documentId of documentIds) {
const meta = await prisma.inboundDocument.findFirst({
where: { id: documentId, userId, deletedAt: null },
select: { title: true, filename: true },
});
if (!meta) return { ok: false, unavailableDocId: documentId };
const context = await loadDocumentChatText(userId, documentId);
if (!context) return { ok: false, unavailableDocId: documentId };
docs.push({
documentId,
title: meta.title,
filename: meta.filename,
text: context.text,
source: context.source,
});
}
return { ok: true, docs };
}
// ─── Inbound + replay injection screen ──────────────────────────────────────
export type FencedInboundScreen =
| { refuse: false }
| {
refuse: true;
reason: CoachRefusalReason | null;
refusalText: string;
replayTurnIndex: number | null;
};
/**
* The inbound refusal + replay-injection guard, verbatim from the single-doc
* route: `detectRefusal` on the inbound message, then re-run over every prior
* USER turn (an injection that slipped the regex on an earlier turn would
* re-enter the prompt every reply). On a hit the caller streams the refusal.
*/
export function screenFencedInbound(args: {
message: string;
priorTurns: FencedTurn[];
locale: Locale;
}): FencedInboundScreen {
const inbound = detectRefusal({
message: args.message,
locale: args.locale,
defaultAllow: true,
});
if (inbound.refuse && inbound.message) {
return {
refuse: true,
reason: inbound.reason,
refusalText: inbound.message,
replayTurnIndex: null,
};
}
for (let i = 0; i < args.priorTurns.length; i++) {
const turn = args.priorTurns[i];
if (turn.role !== "user") continue;
const replayed = detectRefusal({
message: turn.content,
locale: args.locale,
});
if (!replayed.refuse) continue;
return {
refuse: true,
reason: replayed.reason,
replayTurnIndex: i,
refusalText:
replayed.message ??
(args.locale === "de"
? "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
: "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."),
};
}
return { refuse: false };
}
/**
* Stream a refusal as a single `token` frame + `done`, persisting the user turn
* + the refusal message against an EXISTING conversation (the caller resolves /
* creates it first, so the rail shows the attempt). No provider call.
*/
export async function streamFencedRefusal(args: {
conversationId: string;
message: string;
refusalText: string;
}): Promise<Response> {
await appendMessage({
conversationId: args.conversationId,
role: "user",
content: args.message,
});
const refusalMessage = await appendMessage({
conversationId: args.conversationId,
role: "assistant",
content: args.refusalText,
providerType: "refusal",
});
const stream = createSseStream((controller) => {
controller.enqueue(
encodeFencedFrame({ type: "token", token: args.refusalText }),
);
controller.enqueue(
encodeFencedFrame({
type: "done",
conversationId: args.conversationId,
messageId: refusalMessage.id,
}),
);
});
return new Response(stream, { status: 200, headers: FENCED_SSE_HEADERS });
}
// ─── The turn pipeline ──────────────────────────────────────────────────────
export interface StreamFencedReplyArgs {
userId: string;
conversationId: string;
/** The LIVE attached documents, already owner-scope-loaded (§loadFencedDocuments). */
docs: FencedDocContext[];
priorTurns: FencedTurn[];
message: string;
/** Reply language contract ("en" | "de"). */
contractLocale: "en" | "de";
/** i18n locale for outbound-guard fallbacks. */
locale: Locale;
signal: AbortSignal;
/**
* The capabilities this turn is answered under, beside `documentAi`, which
* every fenced turn needs because it reads documents. The Coach's fenced
* chat adds `coach`; the vault's single-document chat adds nothing.
*/
alsoRequires: readonly AiCapabilityKey[];
}
/**
* Run ONE fenced turn against a resolved conversation and its live documents, and
* return the SSE response. May throw `AiUnavailableError` (403, rendered as
* JSON by the api-handler) BEFORE any egress — the capability re-check runs
* first.
*/
export async function streamFencedReply(
args: StreamFencedReplyArgs,
): Promise<Response> {
const { userId, conversationId, docs, message, contractLocale, locale } =
args;
// ── Provider resolution: document order (local-first, codex last), SINGLE
// pick, NO cascade — so the exact egress equals the checked target. The pick
// re-checks `documentAi` for that provider, consent receipt included; a
// refusal is thrown (403) before any egress. ──
const { pick, withheld } = await resolveDocumentTextProvider(userId);
if (withheld) throw withheld;
if (!pick) {
annotate({ action: { name: "documents.chat.noProvider" } });
return streamFencedError("documents.chat.provider.none");
}
// ── Every other capability the entry point answers under, for the same
// provider, BEFORE budget and BEFORE the user turn is persisted. ──
for (const key of args.alsoRequires) {
await assertAiEgress(key, userId, [pick.providerType]);
}
annotate({
action: { name: "documents.chat.consent_check" },
meta: {
documentIds: docs.map((doc) => doc.documentId),
provider: pick.providerType,
},
});
// Persist the user's turn first so it's on disk regardless of the outcome.
await appendMessage({ conversationId, role: "user", content: message });
// ── Budget reservation (atomic, before the provider call) ──
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.documentChat.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
resolveCostOwner([{ providerType: pick.entry.providerType }]),
"coach",
);
if (!reservation.allowed) {
annotate({ action: { name: "documents.chat.budget.exceeded" } });
return streamFencedError("documents.chat.budget.exceeded");
}
// ── Prompt: persona + shared safety spine + EACH document's header + fence,
// NO health snapshot. Combined-context truncation is honest + annotated. ──
const fencedDocs: FencedDoc[] = docs.map((d) => ({
title: d.title,
filename: d.filename,
text: d.text,
}));
const { prompt: systemPrompt, perDoc } = buildFencedChatSystemPrompt(
contractLocale,
fencedDocs,
);
if (perDoc.some((d) => d.truncated)) {
annotate({
action: { name: "documents.chat.context_truncated" },
meta: {
docs: perDoc.map((d, i) => ({
documentId: docs[i].documentId,
bytes: d.bytes,
truncated: d.truncated,
})),
},
});
}
const window: FencedTurn[] = [
...args.priorTurns.slice(-HISTORY_TURN_CAP),
{ role: "user", content: message },
];
const transcript = window
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
.join("\n\n");
const userPrompt = `CONVERSATION
${transcript}
Reply now as the assistant, grounded ONLY in the documents above, in ${
contractLocale === "de" ? "German" : "English"
}.`;
// The authoritative grounding set: the LIVE attachments' texts only. Never
// history, never a snapshot (none exists) — recomputed from `docs` this turn.
const groundingSources = docs.map((d) => d.text);
type Outcome =
| {
ok: true;
replyText: string;
messageId: string;
totalTokens: number;
model: string | null;
}
| { ok: false; code: string };
async function produceReply(): Promise<Outcome> {
let result;
try {
const fallback = await runStreamingRawCompletionWithFallback({
surface: "coach",
userId,
// Single-provider — the document-ordered pick, no cascade.
providers: [pick!.entry],
onDelta: () => {},
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: AI_BUDGETS.documentChat.temperature,
maxTokens: AI_BUDGETS.documentChat.maxTokens,
signal: args.signal,
}),
});
result = fallback.result;
} catch (err) {
await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, {
servedBy: null,
reservedOwner: reservation.owner,
}).catch(() => {});
if (err instanceof AllProvidersFailedError) {
const allRateLimited =
err.attempts.length > 0 &&
err.attempts.every((a) => a.httpStatus === 429);
annotate({
action: { name: "documents.chat.providerFailed" },
meta: {
attempts: err.attempts.length,
credentialExpired: err.primaryCredentialExpired,
},
});
if (err.primaryCredentialExpired) {
return {
ok: false,
code: "documents.chat.provider.credential_expired",
};
}
return {
ok: false,
code: allRateLimited
? "documents.chat.provider.rate_limited"
: "documents.chat.provider.unavailable",
};
}
annotate({
action: { name: "documents.chat.providerFailed" },
meta: { attempts: 1, unwrapped: true },
});
return { ok: false, code: "documents.chat.provider.unavailable" };
}
const totalTokens = result.tokensUsed ?? 0;
const cachedTokens = result.cachedInputTokens ?? 0;
await reconcileSpend(
userId,
reservation.reserved,
totalTokens,
dateKey,
cachedTokens,
{ servedBy: pick!.entry.providerType, reservedOwner: reservation.owner },
).catch(() => {});
let replyText = (result.content ?? "").trim();
if (!replyText) return { ok: false, code: "documents.chat.provider.empty" };
// ── Outbound safety screen (dose-prescription / fabricated risk) ──
const outbound = screenCoachReply(replyText, locale);
if (outbound.block && outbound.reason) {
replyText = coachOutboundFallback(outbound.reason, locale);
annotate({
action: { name: "documents.chat.outbound_blocked" },
meta: { reason: outbound.reason },
});
await auditLog("documents.chat.outbound_blocked", {
userId,
details: { conversationId, reason: outbound.reason },
});
}
// ── Numeric grounding — authoritative set = the LIVE attachments' numbers ──
// v1.32.14 — count of figures withheld from this reply (each rewritten to the
// elision mark), hoisted so the minimal provenance envelope below carries it.
let unverifiedStripped = 0;
if (!outbound.block) {
const unverified = findUnverifiedCoachNumbers(
replyText,
groundingSources,
locale,
);
if (unverified.length > 0) {
const { prose, stripped } = stripUnverifiedNumbers(
replyText,
unverified,
);
replyText = prose;
unverifiedStripped = stripped;
annotate({
action: { name: "documents.chat.number_unverified" },
meta: {
flagged: unverified.length,
stripped,
tokens: unverified.slice(0, 6).map((u) => u.source),
},
});
}
}
const assistant = await appendMessage({
conversationId,
role: "assistant",
content: replyText,
// v1.32.14 — the fenced path otherwise persists no provenance; when the
// guard withheld a figure, ride a MINIMAL envelope so the per-message notice
// renders and survives reload. Count only, no values, no windows/metrics.
metricSource:
unverifiedStripped > 0
? { windows: [], metrics: [], unverifiedFigures: unverifiedStripped }
: null,
providerType: pick!.providerType,
tokensUsed: totalTokens || null,
model: result.model ?? null,
});
annotate({
action: { name: "documents.chat.replied" },
meta: {
provider: pick!.providerType,
tokens: totalTokens,
conversationId,
attachmentCount: docs.length,
documentIds: docs.map((d) => d.documentId),
textSource: docs.map((d) => d.source),
historyTurns: window.length,
},
});
return {
ok: true,
replyText,
messageId: assistant.id,
totalTokens,
model: result.model ?? null,
};
}
const stream = createSseStream(async (controller) => {
const heartbeat = setInterval(() => {
controller.enqueue(HEARTBEAT_FRAME);
}, HEARTBEAT_MS);
let outcome: Outcome;
try {
outcome = await produceReply();
} catch (err) {
clearInterval(heartbeat);
annotate({
action: { name: "documents.chat.streamError" },
meta: { message: err instanceof Error ? err.name : "unknown" },
});
if (!controller.signal.aborted) {
controller.enqueue(
encodeFencedFrame({
type: "error",
code: "documents.chat.provider.unavailable",
message: "documents.chat.provider.unavailable",
}),
);
}
return;
}
clearInterval(heartbeat);
if (!outcome.ok) {
if (!controller.signal.aborted) {
controller.enqueue(
encodeFencedFrame({
type: "error",
code: outcome.code,
message: outcome.code,
}),
);
}
return;
}
for (const tok of tokeniseForStreaming(outcome.replyText)) {
if (controller.signal.aborted) return;
controller.enqueue(encodeFencedFrame({ type: "token", token: tok }));
await flushTick();
}
if (controller.signal.aborted) return;
controller.enqueue(
encodeFencedFrame({
type: "done",
conversationId,
messageId: outcome.messageId,
usage: {
totalTokens: outcome.totalTokens || null,
model: outcome.model,
},
}),
);
});
return new Response(stream, { status: 200, headers: FENCED_SSE_HEADERS });
}