From 6e84048217416066594c9dfe80f97df2b3bbd92d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 02:44:55 +0200 Subject: [PATCH] feat(documents): document-chat SSE route + history GET + OpenAPI (no-tools, no-snapshot, fenced, consent/budget/rate) --- docs/api/openapi.yaml | 226 ++++++ .../api/documents/inbound/[id]/chat/route.ts | 641 ++++++++++++++++++ src/lib/documents/content-index.ts | 15 + src/lib/openapi/routes/documents.ts | 123 ++++ src/lib/validations/inbound-documents.ts | 37 + 5 files changed, 1042 insertions(+) create mode 100644 src/app/api/documents/inbound/[id]/chat/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c250fab17..fef21297e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9389,6 +9389,93 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/documents/inbound/{id}/chat: + post: + tags: + - Documents + summary: Chat about a document (streaming reply) + description: "v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent + Events (`text/event-stream`, not JSON: one `data: \\n\\n` frame per event). Frame `type` is `token` (a + chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message + }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is + grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document + text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are + injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own + figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). + AI-consent-gated (403 `consent.ai.required` for an external provider), budget- and rate-limited. Omitting + `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or + Bearer." + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentChatRequest" + responses: + "200": + description: Server-Sent Events stream of `token` / `done` / `error` frames. + content: + text/event-stream: + schema: + type: string + description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." + "401": *a1 + "403": + description: "AI consent required for an external provider (`errorCode: consent.ai.required`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + get: + tags: + - Documents + summary: Read a document's chat history + description: v1.27.33 — with `conversationId`, returns that one thread's messages (decrypted server-side, oldest-first, + document- and owner-scoped); without it, the paginated list of the document's chat threads for the sheet's rail. + A foreign / unknown id maps to 404 (never 403). Auth via cookie or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: conversationId + in: query + required: false + schema: + type: string + description: When set, returns that thread's messages. + - name: cursor + in: query + required: false + schema: + type: string + description: List pagination cursor (id of the last item). + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 50 + responses: + "200": + description: Either the conversation detail (with `messages`) or the paginated conversation list for the document. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentChatHistoryEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/documents/inbound/reindex: post: tags: @@ -11715,6 +11802,24 @@ components: required: - mode - text + DocumentChatRequest: + type: object + properties: + conversationId: + type: string + minLength: 1 + maxLength: 64 + message: + type: string + minLength: 1 + maxLength: 4000 + locale: + type: string + enum: + - en + - de + required: + - message DisclaimerAckRequest: type: object properties: @@ -26823,6 +26928,127 @@ components: - indexed - tokenCount additionalProperties: false + DocumentChatHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentChatHistoryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentChatHistoryResponse: + anyOf: + - $ref: "#/components/schemas/DocumentChatDetail" + - $ref: "#/components/schemas/DocumentChatList" + DocumentChatDetail: + type: object + properties: + id: + type: string + title: + type: string + createdAt: + type: string + updatedAt: + type: string + messageCount: + type: number + messages: + type: array + items: + $ref: "#/components/schemas/DocumentChatMessage" + summary: + anyOf: + - type: string + - type: "null" + required: + - id + - title + - createdAt + - updatedAt + - messageCount + - messages + - summary + additionalProperties: false + DocumentChatMessage: + type: object + properties: + id: + type: string + role: + type: string + enum: + - user + - assistant + content: + type: string + createdAt: + type: string + providerType: + anyOf: + - type: string + - type: "null" + tokensUsed: + anyOf: + - type: number + - type: "null" + model: + anyOf: + - type: string + - type: "null" + required: + - id + - role + - content + - createdAt + - providerType + - tokensUsed + - model + additionalProperties: false + DocumentChatList: + type: object + properties: + conversations: + type: array + items: + $ref: "#/components/schemas/DocumentChatConversation" + nextCursor: + anyOf: + - type: string + - type: "null" + required: + - conversations + - nextCursor + additionalProperties: false + DocumentChatConversation: + type: object + properties: + id: + type: string + title: + type: string + createdAt: + type: string + updatedAt: + type: string + messageCount: + type: number + required: + - id + - title + - createdAt + - updatedAt + - messageCount + additionalProperties: false DocumentReindexEnvelope: type: object properties: diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts new file mode 100644 index 000000000..9b84ad6e8 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -0,0 +1,641 @@ +/** + * v1.27.33 (Document vault P4) — chat about ONE stored document. + * + * POST streams a grounded prose reply about a single document's text as + * Server-Sent Events; GET reads the document's chat history. The conversation + * persists (encrypted) as a `CoachConversation` with `documentId` set, reusing + * the entire Coach message store, so re-opening the document shows the thread. + * + * SECURITY MODEL (the heart of this feature — untrusted document text enters an + * LLM prompt): + * - NO TOOLS. A single completion per turn, never the Coach tool loop. There + * is nothing an injected instruction could DO — no write, no other resource. + * - NO HEALTH SNAPSHOT (research D3). The ONLY context is this one document's + * text; the user's health record is never injected. + * - PROMPT-INJECTION FENCING. The document text is fenced as DATA via + * `fenceDocument` (marker pair + marker-scrubbing) inside the system prompt, + * which states the fenced content is a document to answer questions about, + * not instructions to follow. `detectRefusal` guards the inbound message and + * REPLAYS over every prior user turn on reload; `screenCoachReply` guards the + * outbound reply (dose-prescription / fabricated-risk-score). + * - NUMERIC GROUNDING. `findUnverifiedCoachNumbers`, pointed at the document's + * own numbers as the authoritative set, strips any figure the model invents. + * - CONSENT + BUDGET + RATE. `assertDocumentEgressConsent` (the document is + * sent to the provider) on the document-ordered pick (local-first, codex + * last), the shared AI budget ledger, and a per-user `document-chat` rate + * bucket. Answers render as PLAIN React text on the client (no markdown lib). + * - INDEXED DOCUMENTS ONLY. The chat is available only for a document that has + * been content-indexed (its text is the grounding); 422 otherwise. + */ +import { type NextRequest } from "next/server"; + +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { createSseStream } from "@/lib/sse/create-stream"; + +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; +import { + AllProvidersFailedError, + runStreamingRawCompletionWithFallback, +} from "@/lib/ai/provider-runner"; +import { singleUserTurn } from "@/lib/ai/types"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, + resolveDailyCap, +} from "@/lib/ai/coach/budget"; +import { detectRefusal } from "@/lib/ai/coach/refusal"; +import { + screenCoachReply, + coachOutboundFallback, +} from "@/lib/ai/coach/outbound-guard"; +import { + findUnverifiedCoachNumbers, + stripUnverifiedNumbers, +} from "@/lib/ai/coach/coach-prose-grounding"; +import { + appendMessage, + createConversation, + fetchConversationWithMessages, + listConversations, +} from "@/lib/ai/coach/persistence"; +import type { CoachStreamEvent } from "@/lib/ai/coach/types"; + +import { + DOCUMENT_CHAT_BUCKET, + DOCUMENT_CHAT_LIMIT_PER_MINUTE, + DOCUMENT_CHAT_WINDOW_MS, + loadOwnedDocument, +} from "@/lib/documents/ai-route-support"; +import { loadDocumentChatText } from "@/lib/documents/content-index"; +import { buildDocumentChatSystemPrompt } from "@/lib/documents/document-chat-prompt"; +import { resolveDocumentTextProvider } from "@/lib/documents/provider-order"; +import { + documentChatHistoryQuerySchema, + documentChatRequestSchema, +} from "@/lib/validations/inbound-documents"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const SSE_HEADERS: Record = { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", +}; + +/** Keepalive comment frame so a reverse proxy never idle-drops a slow backend. */ +const HEARTBEAT_MS = 12_000; +const HEARTBEAT_FRAME = new TextEncoder().encode(": ka\n\n"); + +/** Prior turns kept verbatim in the per-call window — document chats are short. */ +const HISTORY_TURN_CAP = 20; + +function encodeFrame(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 { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** A standalone SSE stream carrying a single structured error frame (HTTP 200). */ +function streamError(code: string): Response { + const stream = createSseStream((controller) => { + controller.enqueue(encodeFrame({ type: "error", code, message: code })); + }); + return new Response(stream, { status: 200, headers: SSE_HEADERS }); +} + +interface Turn { + role: "user" | "assistant"; + content: string; +} + +// ─── POST — send a turn, stream the reply ─────────────────────────────────── + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const userId = user.id; + + const gate = await requireModuleEnabled(userId, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const document = await loadOwnedDocument(userId, id); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + // The chat is available ONLY for a content-indexed document — its text is + // the sole grounding. Prefer the verbatim capture; fall back to normalised. + const context = await loadDocumentChatText(userId, id); + if (!context) { + return apiError("This document has not been indexed for chat yet.", 422, { + errorCode: "documents.inbound.notIndexed", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = documentChatRequestSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid document chat request", 422, { + errorCode: "documents.chat.invalid", + }); + } + const { conversationId, message, locale: bodyLocale } = parsed.data; + + // Per-user request-rate ceiling in front of the daily budget gate. + const rl = await checkRateLimit( + `${DOCUMENT_CHAT_BUCKET}:${userId}`, + DOCUMENT_CHAT_LIMIT_PER_MINUTE, + DOCUMENT_CHAT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const locale = await resolveServerLocale({ + request, + override: bodyLocale, + userLocale: user.locale ?? null, + }); + const contractLocale = locale === "de" ? "de" : "en"; + + // ── Inbound refusal / injection guard ────────────────────────────── + const refusal = detectRefusal({ message, locale, defaultAllow: true }); + if (refusal.refuse && refusal.message) { + annotate({ + action: { name: "documents.chat.refused" }, + meta: { reason: refusal.reason }, + }); + return streamRefusal({ + userId, + documentId: id, + conversationId, + message, + refusalText: refusal.message, + }); + } + + // ── Conversation resolution (document-scoped) ────────────────────── + let workingConversationId: string; + let priorTurns: Turn[] = []; + if (conversationId) { + const existing = await fetchConversationWithMessages( + userId, + conversationId, + { documentId: id }, + ); + if (!existing) { + // 404, not 403 — never reveal cross-user / cross-document existence. + throw new HttpError(404, "documents.chat.conversationNotFound"); + } + workingConversationId = existing.id; + priorTurns = existing.messages.map((m) => ({ + role: m.role, + content: m.content, + })); + + // Replay-injection guard: an injection that slipped past the regex bank + // on an earlier turn would re-enter the prompt every reply. Re-run the + // detector over every prior user turn; on a hit, refuse. + for (let i = 0; i < priorTurns.length; i++) { + const turn = priorTurns[i]; + if (turn.role !== "user") continue; + const replayed = detectRefusal({ message: turn.content, locale }); + if (!replayed.refuse) continue; + annotate({ + action: { name: "documents.chat.replay_injection" }, + meta: { reason: replayed.reason, turnIndex: i }, + }); + await auditLog("documents.chat.replay_injection", { + userId, + details: { + conversationId: existing.id, + documentId: id, + turnIndex: i, + reason: replayed.reason, + }, + }); + return streamRefusal({ + userId, + documentId: id, + conversationId: existing.id, + message, + refusalText: + replayed.message ?? + (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."), + }); + } + } else { + const created = await createConversation({ + userId, + title: message, + documentId: id, + }); + workingConversationId = created.id; + } + + // ── Provider resolution (document order: local-first, codex last) ── + // No vision needed — the chat grounds on the STORED TEXT, not the image, so + // ANY configured provider can chat. A single pick (no runner cascade) so the + // exact egress equals the picked provider that `assertDocumentEgressConsent` + // gates. + const { pick } = await resolveDocumentTextProvider(userId); + if (!pick) { + annotate({ action: { name: "documents.chat.noProvider" } }); + return streamError("documents.chat.provider.none"); + } + // Consent for the document leaving the machine to a third-party AI. A local + // pick stays ungated; any external pick needs an active receipt (403). + await assertDocumentEgressConsent({ + userId, + providerType: pick.providerType, + surface: "insights", + }); + + // Persist the user's turn first so it's on disk regardless of the outcome. + await appendMessage({ + conversationId: workingConversationId, + 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 }]), + ); + if (!reservation.allowed) { + annotate({ action: { name: "documents.chat.budget.exceeded" } }); + return streamError("documents.chat.budget.exceeded"); + } + + // ── Build the lean prompt: system (persona + safety + FENCED document, + // NO health snapshot) + the conversation transcript. ────────────── + const systemPrompt = buildDocumentChatSystemPrompt( + contractLocale, + context.text, + ); + const window: Turn[] = [ + ...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 document above, in ${ + contractLocale === "de" ? "German" : "English" + }.`; + + type Outcome = + | { + ok: true; + replyText: string; + messageId: string; + totalTokens: number; + model: string | null; + } + | { ok: false; code: string }; + + async function produceReply(): Promise { + let result; + try { + const fallback = await runStreamingRawCompletionWithFallback({ + 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: request.signal, + }), + }); + result = fallback.result; + } catch (err) { + // Provider failed — no tokens billed; refund the full reservation. + await reconcileSpend(userId, reservation.reserved, 0, dateKey).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; + // Tokens were billed regardless of reply quality — reconcile now. + await reconcileSpend( + userId, + reservation.reserved, + totalTokens, + dateKey, + cachedTokens, + ).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); + 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: workingConversationId, reason: outbound.reason }, + }); + } + + // ── Numeric grounding — authoritative set = the DOCUMENT's numbers ── + // A blocked turn already carries canned fallback prose, so skip it. + if (!outbound.block) { + const unverified = findUnverifiedCoachNumbers(replyText, [context!.text]); + if (unverified.length > 0) { + const { prose, stripped } = stripUnverifiedNumbers(replyText, unverified); + replyText = prose; + annotate({ + action: { name: "documents.chat.number_unverified" }, + meta: { + flagged: unverified.length, + stripped, + tokens: unverified.slice(0, 6).map((u) => u.source), + }, + }); + } + } + + // Persist the assistant turn BEFORE streaming; a disconnect still leaves + // the canonical row on disk. + const assistant = await appendMessage({ + conversationId: workingConversationId, + role: "assistant", + content: replyText, + providerType: pick!.providerType, + tokensUsed: totalTokens || null, + model: result.model ?? null, + }); + + annotate({ + action: { name: "documents.chat.replied" }, + meta: { + provider: pick!.providerType, + tokens: totalTokens, + conversationId: workingConversationId, + documentId: id, + historyTurns: window.length, + textSource: context!.source, + }, + }); + + return { + ok: true, + replyText, + messageId: assistant.id, + totalTokens, + model: result.model ?? null, + }; + } + + // ── Stream the body ──────────────────────────────────────────────── + 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( + encodeFrame({ + type: "error", + code: "documents.chat.provider.unavailable", + message: "documents.chat.provider.unavailable", + }), + ); + } + return; + } + clearInterval(heartbeat); + + if (!outcome.ok) { + if (!controller.signal.aborted) { + controller.enqueue( + encodeFrame({ type: "error", code: outcome.code, message: outcome.code }), + ); + } + return; + } + + for (const tok of tokeniseForStreaming(outcome.replyText)) { + if (controller.signal.aborted) return; + controller.enqueue(encodeFrame({ type: "token", token: tok })); + await flushTick(); + } + if (controller.signal.aborted) return; + controller.enqueue( + encodeFrame({ + type: "done", + conversationId: workingConversationId, + messageId: outcome.messageId, + usage: { totalTokens: outcome.totalTokens || null, model: outcome.model }, + }), + ); + }); + + return new Response(stream, { status: 200, headers: SSE_HEADERS }); + }, +); + +/** + * Emit a refusal as a single `token` frame + `done`. No provider call, no + * persisted assistant message — the user message is kept so the rail shows the + * attempt. Mirrors the Coach's `streamRefusal`, document-scoped. + */ +async function streamRefusal(args: { + userId: string; + documentId: string; + conversationId: string | undefined; + message: string; + refusalText: string; +}): Promise { + let conversationId = args.conversationId; + if (!conversationId) { + const created = await createConversation({ + userId: args.userId, + title: args.message, + documentId: args.documentId, + }); + conversationId = created.id; + } else { + const owned = await fetchConversationWithMessages( + args.userId, + conversationId, + { documentId: args.documentId }, + ); + if (!owned) throw new HttpError(404, "documents.chat.conversationNotFound"); + } + + await appendMessage({ + conversationId, + role: "user", + content: args.message, + }); + const refusalMessage = await appendMessage({ + conversationId, + role: "assistant", + content: args.refusalText, + providerType: "refusal", + }); + + const stream = createSseStream((controller) => { + controller.enqueue(encodeFrame({ type: "token", token: args.refusalText })); + controller.enqueue( + encodeFrame({ + type: "done", + conversationId: conversationId!, + messageId: refusalMessage.id, + }), + ); + }); + return new Response(stream, { status: 200, headers: SSE_HEADERS }); +} + +// ─── GET — the document's chat history ────────────────────────────────────── + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const userId = user.id; + + const gate = await requireModuleEnabled(userId, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const document = await loadOwnedDocument(userId, id); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + const url = new URL(request.url); + const parsed = documentChatHistoryQuerySchema.safeParse({ + conversationId: url.searchParams.get("conversationId") ?? undefined, + cursor: url.searchParams.get("cursor") ?? undefined, + limit: url.searchParams.get("limit") ?? undefined, + }); + if (!parsed.success) { + return apiError("Invalid chat history query", 422, { + errorCode: "documents.chat.invalid", + }); + } + + // With a conversationId → that one thread's messages (document-scoped). + if (parsed.data.conversationId) { + const detail = await fetchConversationWithMessages( + userId, + parsed.data.conversationId, + { documentId: id }, + ); + if (!detail) { + return apiError("Conversation not found", 404, { + errorCode: "documents.chat.conversationNotFound", + }); + } + annotate({ + action: { name: "documents.chat.fetch" }, + meta: { documentId: id, messageCount: detail.messageCount }, + }); + return apiSuccess(detail); + } + + // Otherwise → the paginated list of this document's chat threads. + const page = await listConversations({ + userId, + documentId: id, + cursor: parsed.data.cursor, + limit: parsed.data.limit, + }); + annotate({ + action: { name: "documents.chat.list" }, + meta: { documentId: id, count: page.conversations.length }, + }); + return apiSuccess(page); + }, +); diff --git a/src/lib/documents/content-index.ts b/src/lib/documents/content-index.ts index e8d554266..c1d4c4f24 100644 --- a/src/lib/documents/content-index.ts +++ b/src/lib/documents/content-index.ts @@ -201,6 +201,21 @@ export function tokenise(text: string): string[] { return [...seen]; } +/** + * v1.27.33 (Document vault P4) — byte-bound the VERBATIM text for storage, + * WITHOUT the normalisation `normaliseIndexText` applies. Casing, accents, + * section names, and units survive intact so a document chat can cite the + * document faithfully; only the byte length is clamped to the storage budget. + */ +export function captureVerbatimText(raw: string): string { + if (Buffer.byteLength(raw, "utf8") <= MAX_VERBATIM_TEXT_BYTES) return raw; + let out = raw.slice(0, MAX_VERBATIM_TEXT_BYTES); + while (Buffer.byteLength(out, "utf8") > MAX_VERBATIM_TEXT_BYTES) { + out = out.slice(0, -256); + } + return out; +} + /** The HKDF-derived HMAC subkey for the blind index. Never persisted or logged. */ function indexSubkey(): Buffer { return deriveSubkey(INDEX_SUBKEY_INFO); diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 12551afc2..4ee3a82f2 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -19,6 +19,7 @@ import { z } from "zod/v4"; import { documentBulkSchema, + documentChatRequestSchema, documentUpdateSchema, inboundConfirmSchema, inboundFactEditSchema, @@ -31,6 +32,44 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS); const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES); +// ─── Chat about a document (P4) — response shapes ────────────────────────── + +const documentChatConversationSchema = z + .object({ + id: z.string(), + title: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + messageCount: z.number(), + }) + .meta({ id: "DocumentChatConversation" }); + +const documentChatMessageSchema = z + .object({ + id: z.string(), + role: z.enum(["user", "assistant"]), + content: z.string(), + createdAt: z.string(), + providerType: z.string().nullable(), + tokensUsed: z.number().nullable(), + model: z.string().nullable(), + }) + .meta({ id: "DocumentChatMessage" }); + +const documentChatDetailSchema = documentChatConversationSchema + .extend({ + messages: z.array(documentChatMessageSchema), + summary: z.string().nullable(), + }) + .meta({ id: "DocumentChatDetail" }); + +const documentChatListSchema = z + .object({ + conversations: z.array(documentChatConversationSchema), + nextCursor: z.string().nullable(), + }) + .meta({ id: "DocumentChatList" }); + inboundConfirmSchema.meta({ id: "InboundConfirmRequest", description: @@ -794,6 +833,90 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + "/api/documents/inbound/{id}/chat": { + post: { + tags: ["Documents"], + summary: "Chat about a document (streaming reply)", + description: + "v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent Events (`text/event-stream`, not JSON: one `data: \\n\\n` frame per event). Frame `type` is `token` (a chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). AI-consent-gated (403 `consent.ai.required` for an external provider), budget- and rate-limited. Omitting `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or Bearer.", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + ], + requestBody: { + required: true, + content: { + "application/json": { schema: documentChatRequestSchema }, + }, + }, + responses: { + "200": { + description: + "Server-Sent Events stream of `token` / `done` / `error` frames.", + content: { + "text/event-stream": { + schema: { + type: "string", + description: + "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes.", + }, + }, + }, + }, + "403": { + description: + "AI consent required for an external provider (`errorCode: consent.ai.required`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + get: { + tags: ["Documents"], + summary: "Read a document's chat history", + description: + "v1.27.33 — with `conversationId`, returns that one thread's messages (decrypted server-side, oldest-first, document- and owner-scoped); without it, the paginated list of the document's chat threads for the sheet's rail. A foreign / unknown id maps to 404 (never 403). Auth via cookie or Bearer.", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { + name: "conversationId", + in: "query", + required: false, + schema: { type: "string" }, + description: "When set, returns that thread's messages.", + }, + { + name: "cursor", + in: "query", + required: false, + schema: { type: "string" }, + description: "List pagination cursor (id of the last item).", + }, + { + name: "limit", + in: "query", + required: false, + schema: { type: "integer", minimum: 1, maximum: 50 }, + }, + ], + responses: { + "200": { + description: + "Either the conversation detail (with `messages`) or the paginated conversation list for the document.", + content: { + "application/json": { + schema: dataEnvelope( + z + .union([documentChatDetailSchema, documentChatListSchema]) + .meta({ id: "DocumentChatHistoryResponse" }), + "DocumentChatHistoryEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/documents/inbound/reindex": { post: { tags: ["Documents"], diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index f60df5139..3edb9fd83 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -635,3 +635,40 @@ export interface DocumentSuggestionDto { /** 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; + +/** + * 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 +>;