feat(documents): document-chat SSE route + history GET + OpenAPI (no-tools, no-snapshot, fenced, consent/budget/rate)

This commit is contained in:
Marc-André Bombeck committed 2026-07-09 02:44:55 +02:00
1 parent f5e4dc2b94
commit 6e84048217
5 files changed
+1042

No files matched your search

+226
View File
@@ -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: <json>\\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: <json>\\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:
@@ -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<string, string> = {
"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<void> {
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<Outcome> {
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<Response> {
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);
},
);
+15
View File
@@ -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);
+123
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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: <json>\\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: <json>\\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"],
+37
View File
@@ -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<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
>;