mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(documents): document-chat SSE route + history GET + OpenAPI (no-tools, no-snapshot, fenced, consent/budget/rate)
This commit is contained in:
1 parent
f5e4dc2b94
commit
6e84048217
5 files changed
+1042
No files matched your search
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
Reference in new issue
Block a user