mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'fix/docai-governance-rebased' into release/v1.27.31
This commit is contained in:
commit
7b1f077888
31 files changed
+786
-100
No files matched your search
@@ -8905,6 +8905,27 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/documents/inbound/capability:
|
||||
get:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Probe document-AI availability + egress
|
||||
description: "Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read
|
||||
leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so
|
||||
`mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind:
|
||||
`\"local\"` (a self-hosted model — the document never leaves the operator's machine) or `\"external\"` (a
|
||||
third-party AI service). The vault shows a per-egress notice before any external document read; sending a
|
||||
document to any external provider also requires an active AI-consent receipt."
|
||||
responses:
|
||||
"200":
|
||||
description: Document-AI capability flags + egress class.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DocumentAiCapabilityEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/documents/inbound/usage:
|
||||
get:
|
||||
tags:
|
||||
@@ -26265,6 +26286,58 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DocumentAiCapabilityEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DocumentAiCapability"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DocumentAiCapability:
|
||||
type: object
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
mode:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- vision
|
||||
- text
|
||||
- type: "null"
|
||||
reason:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- no-provider
|
||||
- enable-local-ocr
|
||||
- type: "null"
|
||||
pdfSupported:
|
||||
type: boolean
|
||||
egress:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- local
|
||||
- external
|
||||
- type: "null"
|
||||
required:
|
||||
- available
|
||||
- mode
|
||||
- reason
|
||||
- pdfSupported
|
||||
- egress
|
||||
additionalProperties: false
|
||||
DocumentUsageEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -8210,6 +8210,7 @@
|
||||
"close": "Ausblenden"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Dies sendet den Inhalt des Dokuments an deinen konfigurierten KI-Anbieter — es verlässt dieses Gerät zu einem KI-Dienst eines Drittanbieters. Ist der Anbieter ein persönliches Abonnement, gelten dessen eigene Datenschutz-Einstellungen, einschließlich Modelltraining, sofern du es dort nicht deaktiviert hast.",
|
||||
"blockTitle": "Mit KI auslesen",
|
||||
"available": "Lass die KI dieses Dokument lesen — Titel, Art und Datum vorschlagen, zusammenfassen oder den ganzen Text durchsuchbar machen.",
|
||||
"read": "Mit KI auslesen",
|
||||
|
||||
@@ -8210,6 +8210,7 @@
|
||||
"close": "Hide"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "This sends the document's contents to your configured AI provider — it leaves this machine to a third-party AI service. If that provider is a personal subscription plan, its own data-use settings apply, including model training unless you have turned it off there.",
|
||||
"blockTitle": "Read with AI",
|
||||
"available": "Let the AI read this document — suggest a title, type and date, summarise it, or make its full text searchable.",
|
||||
"read": "Read with AI",
|
||||
|
||||
@@ -8210,6 +8210,7 @@
|
||||
"close": "Ocultar"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Esto envía el contenido del documento a tu proveedor de IA configurado — sale de esta máquina hacia un servicio de IA de terceros. Si ese proveedor es una suscripción personal, se aplican sus propios ajustes de uso de datos, incluido el entrenamiento del modelo salvo que lo hayas desactivado allí.",
|
||||
"blockTitle": "Leer con IA",
|
||||
"available": "Deja que la IA lea este documento: sugerir un título, tipo y fecha, resumirlo o hacer que todo su texto sea buscable.",
|
||||
"read": "Leer con IA",
|
||||
|
||||
@@ -8210,6 +8210,7 @@
|
||||
"close": "Masquer"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Ceci envoie le contenu du document à votre fournisseur d'IA configuré — il quitte cette machine vers un service d'IA tiers. Si ce fournisseur est un abonnement personnel, ses propres paramètres d'utilisation des données s'appliquent, y compris l'entraînement du modèle sauf si vous l'avez désactivé là-bas.",
|
||||
"blockTitle": "Lire avec l’IA",
|
||||
"available": "Laissez l’IA lire ce document — suggérer un titre, un type et une date, le résumer ou rendre tout son texte consultable.",
|
||||
"read": "Lire avec l’IA",
|
||||
|
||||
@@ -8210,6 +8210,7 @@
|
||||
"close": "Nascondi"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Questo invia il contenuto del documento al provider IA configurato — lascia questa macchina verso un servizio IA di terze parti. Se quel provider è un abbonamento personale, si applicano le sue impostazioni sull'uso dei dati, incluso l'addestramento del modello a meno che tu non l'abbia disattivato lì.",
|
||||
"blockTitle": "Leggi con l’IA",
|
||||
"available": "Lascia che l’IA legga questo documento: suggerire un titolo, un tipo e una data, riassumerlo o rendere ricercabile tutto il testo.",
|
||||
"read": "Leggi con l’IA",
|
||||
|
||||
@@ -8210,6 +8210,7 @@
|
||||
"close": "Ukryj"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "To wysyła treść dokumentu do skonfigurowanego dostawcy AI — opuszcza ona to urządzenie i trafia do zewnętrznej usługi AI. Jeśli ten dostawca to subskrypcja osobista, obowiązują jego własne ustawienia wykorzystania danych, w tym trenowanie modelu, o ile nie wyłączysz go tam.",
|
||||
"blockTitle": "Odczytaj z AI",
|
||||
"available": "Pozwól AI odczytać ten dokument — zaproponować tytuł, typ i datę, streścić go lub udostępnić cały tekst do wyszukiwania.",
|
||||
"read": "Odczytaj z AI",
|
||||
|
||||
@@ -28,12 +28,13 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn(() => "{}"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-27"),
|
||||
@@ -76,7 +77,7 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -109,7 +110,7 @@ beforeEach(() => {
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract", () => {
|
||||
it("422s without a vision provider and leaves the stored row intact", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* Extraction is no longer part of upload: a document is stored first
|
||||
* (provider-free), and THIS route is the explicit, user-triggered enhancement.
|
||||
* It carries the entire ingest gauntlet that upload used to run inline —
|
||||
* resolve provider → assertConsentForChain → rate-limit → reserveBudget →
|
||||
* runInboundExtraction → reconcileSpend → stage facts — but now against a row
|
||||
* that already exists. Absent a provider this 422s the ENHANCEMENT only; the
|
||||
* stored document is untouched and remains filed.
|
||||
* resolve document provider (local-first, codex last) → assertDocumentEgressConsent
|
||||
* → rate-limit → reserveBudget → runInboundExtraction → reconcileSpend → stage
|
||||
* facts — but now against a row that already exists. Absent a provider this 422s
|
||||
* the ENHANCEMENT only; the stored document is untouched and remains filed.
|
||||
*
|
||||
* Two modes, dispatched on content-type (mirroring the original upload):
|
||||
* - VISION (no JSON body): decrypt the stored original, re-derive its MIME,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -52,9 +52,9 @@ import {
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -200,14 +200,18 @@ async function handleTextExtract(
|
||||
});
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
@@ -298,14 +302,18 @@ async function handleVisionExtract(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
|
||||
@@ -29,11 +29,12 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 7 }),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
@@ -68,7 +69,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
|
||||
@@ -109,7 +110,7 @@ beforeEach(() => {
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
it("vision: transcribes then upserts the index, never facts", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
@@ -137,7 +138,7 @@ describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
});
|
||||
|
||||
it("vision: 422 without a provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
@@ -160,7 +161,7 @@ describe("POST /api/documents/inbound/[id]/index", () => {
|
||||
expect.objectContaining({ source: "text-ocr", providerType: null }),
|
||||
);
|
||||
// No provider was resolved on the text path.
|
||||
expect(resolveVisionProvider).not.toHaveBeenCalled();
|
||||
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("text: 422 when local OCR is not enabled", async () => {
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* Decision (maintainer, 2026-07-07): content indexing is gated on the EXISTING
|
||||
* AI consent / provider gate — there is NO separate `documentsContentIndexEnabled`
|
||||
* toggle (the plan's P2-D8 opt-in was refused to avoid toggle sprawl). The vision
|
||||
* path runs `assertConsentForChain`; the text path rides the local-OCR opt-in the
|
||||
* lab / extract text mode already uses.
|
||||
* path runs `assertDocumentEgressConsent` (any external provider needs an active
|
||||
* receipt; a local pick stays ungated); the text path rides the local-OCR opt-in
|
||||
* the lab / extract text mode already uses — no provider egress at all.
|
||||
*
|
||||
* Persists ONLY AES-256-GCM ciphertext text + opaque HMAC token hashes (A4).
|
||||
*/
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -51,7 +52,7 @@ import {
|
||||
DocumentDescribeError,
|
||||
transcribeDocument,
|
||||
} from "@/lib/documents/describe";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -164,13 +165,17 @@ async function handleVisionIndex(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
|
||||
@@ -27,12 +27,13 @@ vi.mock("@/lib/documents/assist", () => ({
|
||||
runDocumentAssist: vi.fn(),
|
||||
DocumentAssistError: class DocumentAssistError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
@@ -67,7 +68,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { runDocumentAssist } from "@/lib/documents/assist";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -98,7 +99,7 @@ beforeEach(() => {
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/suggest", () => {
|
||||
it("422s without a vision provider and writes nothing", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
@@ -113,7 +114,7 @@ describe("POST /api/documents/inbound/[id]/suggest", () => {
|
||||
});
|
||||
|
||||
it("returns drafts and never persists them", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -45,9 +45,9 @@ import {
|
||||
type LoadedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -133,14 +133,18 @@ async function handleTextSuggest(
|
||||
});
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
@@ -209,14 +213,18 @@ async function handleVisionSuggest(
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
|
||||
@@ -27,12 +27,13 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
transcribeDocument: vi.fn(),
|
||||
DocumentDescribeError: class DocumentDescribeError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
@@ -67,7 +68,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
runDocumentSummary,
|
||||
@@ -105,7 +106,7 @@ beforeEach(() => {
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
} as never);
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
@@ -151,7 +152,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
});
|
||||
|
||||
it("422s without a provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -47,9 +47,9 @@ import {
|
||||
type DescribeInput,
|
||||
} from "@/lib/documents/describe";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -188,13 +188,17 @@ async function handleTextSummary(
|
||||
return finishSummary(request, userId, document.id, "text", mode, result);
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
@@ -235,13 +239,17 @@ async function handleVisionSummary(
|
||||
document: LoadedDocument,
|
||||
mode: DocumentSummaryMode,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`${DOCUMENT_AI_BUCKET}:${userId}`,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* GET /api/documents/inbound/capability
|
||||
*
|
||||
* Document-scoped AI capability probe (no provider call). Mirrors the labs
|
||||
* `/api/labs/ocr/capability` probe but resolves over the DOCUMENT provider
|
||||
* order (local-first, codex last — governance fix, oauth-investigation
|
||||
* SYNTHESIS §1) and adds the vendor-blind `egress` class so the vault UI can
|
||||
* show the "this leaves your machine to a third-party AI" notice BEFORE a read.
|
||||
*
|
||||
* The `mode` / `pdfSupported` / `egress` here reflect exactly what the document
|
||||
* routes (suggest / summary / extract / index) will do, so the affordance the
|
||||
* UI offers never diverges from what the endpoint accepts.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { resolveDocumentAiCapability } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const capability = await resolveDocumentAiCapability(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.ai.capability" },
|
||||
meta: {
|
||||
available: capability.available,
|
||||
mode: capability.mode,
|
||||
reason: capability.reason,
|
||||
pdfSupported: capability.pdfSupported,
|
||||
egress: capability.egress,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(capability);
|
||||
});
|
||||
@@ -9,10 +9,10 @@
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { enqueueContentIndexBackfill } from "@/lib/jobs/document-content-index-backfill";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -46,13 +46,17 @@ export const POST = apiHandler(async (request) => {
|
||||
|
||||
// Fail fast when the precondition is not met so the UI gets immediate
|
||||
// feedback rather than a silently no-op'd job.
|
||||
const { chain, pick } = await resolveVisionProvider(user.id);
|
||||
const { pick } = await resolveDocumentVisionProvider(user.id);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
await assertConsentForChain({ userId: user.id, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId: user.id,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const { enqueued } = await enqueueContentIndexBackfill(user.id);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ function base(
|
||||
aiEnabled: true,
|
||||
indexEnabled: true,
|
||||
unavailableReason: null,
|
||||
egressExternal: false,
|
||||
actionsDisabled: false,
|
||||
onSuggest: noop,
|
||||
suggestPending: false,
|
||||
@@ -142,6 +143,22 @@ describe("<DocumentAiSection>", () => {
|
||||
expect(html).toContain("Not saved");
|
||||
});
|
||||
|
||||
it("shows the vendor-blind egress notice before an external document read", () => {
|
||||
const html = render(base({ aiEnabled: true, egressExternal: true }));
|
||||
expect(html).toContain('data-slot="document-ai-egress-notice"');
|
||||
expect(html).toContain("third-party AI service");
|
||||
// Vendor-blind: never names a specific AI vendor in the document surface.
|
||||
expect(html).not.toContain("OpenAI");
|
||||
expect(html).not.toContain("ChatGPT");
|
||||
// The actions are still offered — the notice informs, it does not block.
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
});
|
||||
|
||||
it("omits the egress notice when the read stays on the local machine", () => {
|
||||
const html = render(base({ aiEnabled: true, egressExternal: false }));
|
||||
expect(html).not.toContain('data-slot="document-ai-egress-notice"');
|
||||
});
|
||||
|
||||
it("hides the Read-with-AI action when indexing is unavailable but keeps suggest", () => {
|
||||
const html = render(base({ aiEnabled: true, indexEnabled: false }));
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* status pill stays, honestly reflecting any local auto-index, and the manual
|
||||
* form below stays fully usable.
|
||||
*/
|
||||
import { FileText, ScanText, WandSparkles } from "lucide-react";
|
||||
import { FileText, ScanText, ShieldAlert, WandSparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -46,6 +46,7 @@ export function DocumentAiSection({
|
||||
aiEnabled,
|
||||
indexEnabled,
|
||||
unavailableReason,
|
||||
egressExternal,
|
||||
actionsDisabled,
|
||||
onSuggest,
|
||||
suggestPending,
|
||||
@@ -72,6 +73,12 @@ export function DocumentAiSection({
|
||||
aiEnabled: boolean;
|
||||
indexEnabled: boolean;
|
||||
unavailableReason: "no-provider" | "enable-local-ocr" | null;
|
||||
/**
|
||||
* True when a document read will egress to a third-party AI service (any
|
||||
* provider other than a self-hosted local model). Drives the vendor-blind
|
||||
* "this leaves your machine" notice shown before the AI actions.
|
||||
*/
|
||||
egressExternal: boolean;
|
||||
/** Capability still resolving — the transport mode isn't known yet. */
|
||||
actionsDisabled: boolean;
|
||||
onSuggest: () => void;
|
||||
@@ -126,6 +133,17 @@ export function DocumentAiSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{egressExternal ? (
|
||||
<div
|
||||
data-slot="document-ai-egress-notice"
|
||||
role="note"
|
||||
className="border-border text-muted-foreground flex items-start gap-2 rounded-lg border border-dashed px-3 py-2.5 text-xs"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">{t("documents.ai.egressNotice")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{aiEnabled ? (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
|
||||
@@ -505,6 +505,7 @@ export function DocumentDetailSheet({
|
||||
aiEnabled={aiEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
egressExternal={capability.data?.egress === "external"}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
|
||||
@@ -17,8 +17,8 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet, ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr";
|
||||
import type {
|
||||
DocumentAiCapabilityDto,
|
||||
DocumentSuggestionDto,
|
||||
DocumentSummaryMode,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
@@ -34,16 +34,18 @@ import {
|
||||
export type DocumentDescribeResult = { summary: string } | { text: string };
|
||||
|
||||
/**
|
||||
* The shared OCR capability probe (`/api/labs/ocr/capability`) reused verbatim:
|
||||
* the same server resolution both the lab-OCR and the document AI routes gate
|
||||
* on, so the client picks the transport the endpoint accepts. Availability of
|
||||
* the affordance itself is gated on `usage.assistAvailable`; this read only
|
||||
* resolves the transport `mode` + PDF support.
|
||||
* The document-scoped AI capability probe (`/api/documents/inbound/capability`).
|
||||
* Resolved over the DOCUMENT provider order (local-first, codex last), so the
|
||||
* transport `mode` + PDF support match what the document routes do, and it
|
||||
* carries the vendor-blind `egress` class the detail sheet uses to warn before a
|
||||
* read leaves the machine. Availability of the affordance itself is still gated
|
||||
* on `usage.assistAvailable`.
|
||||
*/
|
||||
export function useDocumentAiCapability(enabled: boolean) {
|
||||
return useQuery<OcrCapabilityDto>({
|
||||
queryKey: queryKeys.ocrCapability(),
|
||||
queryFn: () => apiGet<OcrCapabilityDto>("/api/labs/ocr/capability"),
|
||||
return useQuery<DocumentAiCapabilityDto>({
|
||||
queryKey: queryKeys.inboundDocumentAiCapability(),
|
||||
queryFn: () =>
|
||||
apiGet<DocumentAiCapabilityDto>("/api/documents/inbound/capability"),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ vi.mock("@/lib/consent/receipts", () => ({
|
||||
import {
|
||||
ConsentRequiredError,
|
||||
assertConsentForChain,
|
||||
assertDocumentEgressConsent,
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
isExternalDocumentEgress,
|
||||
} from "../consent-guard";
|
||||
import { latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import type { ProviderChainResolved } from "../provider-runner";
|
||||
@@ -168,3 +170,67 @@ describe("assertConsentForChain", () => {
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExternalDocumentEgress", () => {
|
||||
it("treats only the self-hosted local provider as non-egress", () => {
|
||||
expect(isExternalDocumentEgress("local")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats every external provider as document egress", () => {
|
||||
for (const p of ["codex", "openai", "anthropic", "admin-openai"]) {
|
||||
expect(isExternalDocumentEgress(p)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertDocumentEgressConsent", () => {
|
||||
beforeEach(() => mockedLatest.mockReset());
|
||||
|
||||
it("never reads consent and never throws for a LOCAL document pick", async () => {
|
||||
grant([]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "local",
|
||||
surface: "insights",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockedLatest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The live gap the governance fix closes: codex was ungated for documents.
|
||||
it("REQUIRES a receipt to send a document to codex (the closed gap)", async () => {
|
||||
grant([]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "codex",
|
||||
surface: "insights",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
});
|
||||
|
||||
it("requires a receipt for BYOK document egress too (openai / anthropic)", async () => {
|
||||
grant([]);
|
||||
for (const p of ["openai", "anthropic", "admin-openai"]) {
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: p,
|
||||
surface: "insights",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
}
|
||||
});
|
||||
|
||||
it("proceeds for codex WITH an active document-class receipt", async () => {
|
||||
grant(["ai_insights_only"]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "codex",
|
||||
surface: "insights",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -118,3 +118,50 @@ export async function assertConsentForChain(args: {
|
||||
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
|
||||
throw new ConsentRequiredError(args.surface);
|
||||
}
|
||||
|
||||
/**
|
||||
* The DOCUMENT-class consent gate (governance fix, oauth-investigation
|
||||
* SYNTHESIS §2).
|
||||
*
|
||||
* The self-snapshot gate above leaves BYOK / codex / local UNGATED on the
|
||||
* theory that "configuring the provider was the consent act." That theory holds
|
||||
* for a metrics snapshot the account owner sends about themselves. It is too
|
||||
* thin for an uploaded medical DOCUMENT: sending a scanned discharge letter to
|
||||
* ANY third-party AI is the egress data-protection law cares about, and the
|
||||
* codex (ChatGPT-subscription) backend trains on consumer content by default.
|
||||
*
|
||||
* So for the document surfaces the gate is stricter and PICK-based: the vault
|
||||
* AI routes call the single resolved provider directly (no runner cascade), so
|
||||
* the exact egress is the picked provider. A `local` pick never leaves the
|
||||
* machine and stays ungated; EVERY external pick (codex, BYOK openai/anthropic,
|
||||
* the operator's admin key) requires an active document-class consent receipt.
|
||||
*/
|
||||
const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
|
||||
|
||||
/**
|
||||
* True when reading a document through this provider egresses it OFF the machine
|
||||
* to a third-party AI service. Only the self-hosted `local` provider keeps the
|
||||
* document on the operator's own infrastructure.
|
||||
*/
|
||||
export function isExternalDocumentEgress(providerType: string): boolean {
|
||||
return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the document-class consent precondition for the provider that will
|
||||
* actually receive the document. No-op for a `local` pick (nothing leaves the
|
||||
* machine); for any external pick, throw `ConsentRequiredError` unless an active
|
||||
* receipt of the surface's mapped kind (or `ai_full`) is on file.
|
||||
*
|
||||
* Call this AFTER the document provider is picked and BEFORE the first
|
||||
* `generateCompletion` on it.
|
||||
*/
|
||||
export async function assertDocumentEgressConsent(args: {
|
||||
userId: string;
|
||||
providerType: string;
|
||||
surface: ConsentSurface;
|
||||
}): Promise<void> {
|
||||
if (!isExternalDocumentEgress(args.providerType)) return;
|
||||
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
|
||||
throw new ConsentRequiredError(args.surface);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Document-class provider order (governance fix, oauth-investigation
|
||||
* SYNTHESIS §1). Pins: for a DOCUMENT read the chain is reprioritised
|
||||
* local-first with codex (ChatGPT-subscription OAuth) LAST, and the egress
|
||||
* class the vault notice reads is vendor-blind local/external. Coach / insights
|
||||
* do not use these helpers, so their app-wide order is untouched.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
documentEgressClass,
|
||||
reorderChainForDocumentClass,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "../provider-order";
|
||||
import {
|
||||
resolveVisionProvider,
|
||||
type VisionProviderPick,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
|
||||
function entry(providerType: string): ProviderChainResolved {
|
||||
return {
|
||||
providerType: providerType as ProviderChainResolved["providerType"],
|
||||
instance: {
|
||||
type: providerType,
|
||||
generateCompletion: vi.fn(),
|
||||
} as unknown as ProviderChainResolved["instance"],
|
||||
};
|
||||
}
|
||||
|
||||
const order = (chain: ProviderChainResolved[]) =>
|
||||
reorderChainForDocumentClass(chain).map((e) => e.providerType);
|
||||
|
||||
describe("reorderChainForDocumentClass", () => {
|
||||
it("demotes codex last and lifts local first for the default chain", () => {
|
||||
// The persisted default chain the audit flagged: codex at priority 1.
|
||||
const chain = [
|
||||
entry("codex"),
|
||||
entry("openai"),
|
||||
entry("anthropic"),
|
||||
entry("local"),
|
||||
entry("admin-openai"),
|
||||
];
|
||||
expect(order(chain)).toEqual([
|
||||
"local",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"admin-openai",
|
||||
"codex",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps codex behind local even when codex is the only cheap option", () => {
|
||||
expect(order([entry("codex"), entry("local")])).toEqual(["local", "codex"]);
|
||||
});
|
||||
|
||||
it("is stable within a rank tier (preserves user order for BYOK keys)", () => {
|
||||
expect(order([entry("anthropic"), entry("openai")])).toEqual([
|
||||
"anthropic",
|
||||
"openai",
|
||||
]);
|
||||
expect(order([entry("openai"), entry("anthropic")])).toEqual([
|
||||
"openai",
|
||||
"anthropic",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const chain = [entry("codex"), entry("local")];
|
||||
reorderChainForDocumentClass(chain);
|
||||
expect(chain.map((e) => e.providerType)).toEqual(["codex", "local"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentEgressClass", () => {
|
||||
it("classifies local as on-machine and everything else as external", () => {
|
||||
expect(documentEgressClass("local")).toBe("local");
|
||||
expect(documentEgressClass("codex")).toBe("external");
|
||||
expect(documentEgressClass("openai")).toBe("external");
|
||||
expect(documentEgressClass("anthropic")).toBe("external");
|
||||
expect(documentEgressClass("admin-openai")).toBe("external");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDocumentVisionProvider", () => {
|
||||
beforeEach(() => vi.mocked(resolveVisionProvider).mockReset());
|
||||
|
||||
it("invokes the shared resolver with the document reorder that demotes codex", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
localOcrEnabled: false,
|
||||
pick: null,
|
||||
} as VisionProviderPick);
|
||||
|
||||
await resolveDocumentVisionProvider("u1");
|
||||
|
||||
const call = vi.mocked(resolveVisionProvider).mock.calls[0]!;
|
||||
expect(call[0]).toBe("u1");
|
||||
const reorder = call[1]!.reorder!;
|
||||
// The reorder the document resolver hands down puts a codex-first chain
|
||||
// local-first — proving documents never default to the subscription path.
|
||||
expect(
|
||||
reorder([entry("codex"), entry("local")]).map((e) => e.providerType),
|
||||
).toEqual(["local", "codex"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Document-class AI provider resolution (governance fix, oauth-investigation
|
||||
* SYNTHESIS §1).
|
||||
*
|
||||
* A three-audit investigation found that for uploaded medical DOCUMENTS the
|
||||
* app-wide provider chain is the wrong default: `codex` (the
|
||||
* ChatGPT-subscription OAuth backend) sits at chain priority 1, ahead of BYOK
|
||||
* and local, and OpenAI's consumer policy allows training on that content by
|
||||
* default. HealthLog is privacy-first, so a scanned discharge letter must NOT
|
||||
* default to the train-by-default backend.
|
||||
*
|
||||
* The fix is scoped to the DOCUMENT class only — Coach / insights keep the
|
||||
* cost-first app-wide order untouched. This module reprioritises the resolved
|
||||
* provider chain for the vault's AI surfaces (suggest / summary / extract /
|
||||
* index / reindex / backfill):
|
||||
*
|
||||
* local (no egress) → BYOK no-train API (openai / anthropic) → operator's
|
||||
* admin key → codex (ChatGPT-subscription OAuth) LAST, as an explicit,
|
||||
* consented opt-in.
|
||||
*
|
||||
* The reorder only decides WHICH configured provider is preferred; it never
|
||||
* invents a provider. Egress consent (`assertDocumentEgressConsent`) and the
|
||||
* per-egress vendor-blind UI notice sit on top of this order — the reorder
|
||||
* keeps codex from being the silent default, the consent gate keeps any
|
||||
* external egress from happening without an active receipt.
|
||||
*/
|
||||
import { isExternalDocumentEgress } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
type ChainReorder,
|
||||
type VisionProviderPick,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import type {
|
||||
DocumentAiCapabilityDto,
|
||||
DocumentEgressClass,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Preference rank for a provider when the payload is a DOCUMENT. Lower wins.
|
||||
* Local keeps the document on the machine (rank 0); BYOK no-train API keys are
|
||||
* next (rank 1); the operator's shared no-train key follows (rank 2); the
|
||||
* ChatGPT-subscription OAuth path (`codex`) is LAST (rank 3) because it trains
|
||||
* on consumer content by default and cannot be verified opted-out from here.
|
||||
*/
|
||||
function documentProviderRank(providerType: string): number {
|
||||
switch (providerType) {
|
||||
case "local":
|
||||
return 0;
|
||||
case "openai":
|
||||
case "anthropic":
|
||||
case "admin-key":
|
||||
return 1;
|
||||
case "admin-openai":
|
||||
return 2;
|
||||
case "codex":
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a resolved chain for the document class: stable sort by
|
||||
* `documentProviderRank`, so within a rank tier the user's own chain order is
|
||||
* preserved (a stable sort keeps insertion order on ties). Pure — returns a new
|
||||
* array, never mutates the input.
|
||||
*/
|
||||
export const reorderChainForDocumentClass: ChainReorder = (chain) => {
|
||||
return [...chain].sort(
|
||||
(a, b) =>
|
||||
documentProviderRank(a.providerType) -
|
||||
documentProviderRank(b.providerType),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the vision provider for a DOCUMENT read — local-first, codex last.
|
||||
* Same shape as `resolveVisionProvider`; the returned `chain` is already in
|
||||
* document order and the `pick` is the first vision-capable entry in it.
|
||||
*/
|
||||
export function resolveDocumentVisionProvider(
|
||||
userId: string,
|
||||
): Promise<VisionProviderPick> {
|
||||
return resolveVisionProvider(userId, {
|
||||
reorder: reorderChainForDocumentClass,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the text-mode (browser-OCR) structuring provider for a DOCUMENT —
|
||||
* local-first, codex last. The `pick` is the first entry of the reordered
|
||||
* chain.
|
||||
*/
|
||||
export function resolveDocumentTextProvider(userId: string) {
|
||||
return resolveTextProvider(userId, {
|
||||
reorder: reorderChainForDocumentClass,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a picked provider's egress for the per-egress UI notice. Vendor-blind
|
||||
* by design — "local" (stays on the machine) vs "external" (a third-party AI
|
||||
* service); the copy never names a vendor.
|
||||
*/
|
||||
export function documentEgressClass(providerType: string): DocumentEgressClass {
|
||||
return isExternalDocumentEgress(providerType) ? "external" : "local";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the document AI capability for the vault UI. The `mode` /
|
||||
* `pdfSupported` / `egress` reflect the DOCUMENT provider order (local-first),
|
||||
* so the affordance the UI offers matches exactly what the document routes do.
|
||||
*/
|
||||
export async function resolveDocumentAiCapability(
|
||||
userId: string,
|
||||
): Promise<DocumentAiCapabilityDto> {
|
||||
const { chain, pick, localOcrEnabled } =
|
||||
await resolveDocumentVisionProvider(userId);
|
||||
|
||||
// A vision-capable provider is available — the read runs directly over the
|
||||
// stored original. Egress follows the picked provider.
|
||||
if (pick) {
|
||||
return {
|
||||
available: true,
|
||||
mode: "vision",
|
||||
reason: null,
|
||||
pdfSupported: pick.pdfSupported,
|
||||
egress: documentEgressClass(pick.providerType),
|
||||
};
|
||||
}
|
||||
|
||||
// Nothing configured at all — no read, regardless of the local-OCR toggle.
|
||||
if (chain.length === 0) {
|
||||
return {
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: "no-provider",
|
||||
pdfSupported: false,
|
||||
egress: null,
|
||||
};
|
||||
}
|
||||
|
||||
// A text-only provider is configured. Local OCR runs in the browser and only
|
||||
// the extracted text is structured by the first provider in document order.
|
||||
if (localOcrEnabled) {
|
||||
return {
|
||||
available: true,
|
||||
mode: "text",
|
||||
reason: null,
|
||||
pdfSupported: false,
|
||||
egress: documentEgressClass(chain[0]!.providerType),
|
||||
};
|
||||
}
|
||||
|
||||
// A text-only provider is configured but local OCR is not enabled.
|
||||
return {
|
||||
available: false,
|
||||
mode: null,
|
||||
reason: "enable-local-ocr",
|
||||
pdfSupported: false,
|
||||
egress: null,
|
||||
};
|
||||
}
|
||||
@@ -25,11 +25,11 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
upsertContentIndex: vi.fn().mockResolvedValue({ tokenCount: 3 }),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
@@ -42,9 +42,9 @@ vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
|
||||
import { runContentIndexBackfillForUser } from "../document-content-index-backfill";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import {
|
||||
assertConsentForChain,
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
@@ -81,7 +81,7 @@ beforeEach(() => {
|
||||
|
||||
describe("runContentIndexBackfillForUser", () => {
|
||||
it("no-ops with no provider", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
@@ -91,8 +91,8 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
});
|
||||
|
||||
it("no-ops when consent is missing", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertConsentForChain).mockRejectedValueOnce(
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertDocumentEgressConsent).mockRejectedValueOnce(
|
||||
new ConsentRequiredError("insights" as never),
|
||||
);
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
@@ -101,7 +101,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
});
|
||||
|
||||
it("indexes the not-yet-indexed documents", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
// One short page (< PAGE_SIZE) — the walk breaks after it, so a single
|
||||
// findMany return is enough (a trailing once would leak to the next test).
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
@@ -115,7 +115,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
});
|
||||
|
||||
it("stops when the daily budget is reached (resumable)", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValueOnce([
|
||||
{ id: "d1" },
|
||||
{ id: "d2" },
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
* indexing egresses to a provider under the user's key + budget, so it must be
|
||||
* a deliberate, consented act, exactly like the per-document index route.
|
||||
*
|
||||
* Consent: gated on the EXISTING AI consent (`assertConsentForChain`), the same
|
||||
* gate the extract / index routes use — there is no separate per-user toggle
|
||||
* (maintainer decision, 2026-07-07).
|
||||
* Consent: gated on the document-egress consent (`assertDocumentEgressConsent`),
|
||||
* the same gate the extract / index routes use — any external provider needs an
|
||||
* active receipt; a local pick stays ungated. There is no separate per-user
|
||||
* toggle (maintainer decision, 2026-07-07).
|
||||
*
|
||||
* Bounded + resumable: an id-cursor forward walk over the not-yet-indexed set,
|
||||
* capped at `MAX_DOCS_PER_RUN` provider calls per job and stopped the moment the
|
||||
@@ -24,7 +25,7 @@
|
||||
*/
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import {
|
||||
assertConsentForChain,
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
@@ -41,7 +42,7 @@ import {
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const CONTENT_INDEX_BACKFILL_QUEUE = "document-content-index-backfill";
|
||||
@@ -76,10 +77,14 @@ export interface ContentIndexBackfillSummary {
|
||||
export async function runContentIndexBackfillForUser(
|
||||
userId: string,
|
||||
): Promise<ContentIndexBackfillSummary> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) return { indexed: 0, reason: "no-provider" };
|
||||
try {
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ConsentRequiredError) {
|
||||
return { indexed: 0, reason: "no-consent" };
|
||||
|
||||
@@ -39,6 +39,21 @@ function modelForEntry(
|
||||
return providerType === "admin-openai" ? ctx.adminModel : ctx.userModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A transform applied to the resolved provider chain before the vision/text
|
||||
* pick is made. Identity by default (labs behaviour unchanged); the document
|
||||
* class passes a reorder that demotes external/train-by-default providers below
|
||||
* local (see `@/lib/documents/provider-order`).
|
||||
*/
|
||||
export type ChainReorder = (
|
||||
chain: ProviderChainResolved[],
|
||||
) => ProviderChainResolved[];
|
||||
|
||||
export interface ResolveProviderOptions {
|
||||
/** Reorder the resolved chain before picking. Defaults to identity. */
|
||||
reorder?: ChainReorder;
|
||||
}
|
||||
|
||||
/** A vision-capable provider pick: its instance, logical tag, and model. */
|
||||
export interface VisionProviderPick {
|
||||
/** The full resolved chain (for `assertConsentForChain`). */
|
||||
@@ -61,6 +76,7 @@ export interface VisionProviderPick {
|
||||
*/
|
||||
export async function resolveVisionProvider(
|
||||
userId: string,
|
||||
options?: ResolveProviderOptions,
|
||||
): Promise<VisionProviderPick> {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -84,16 +100,22 @@ export async function resolveVisionProvider(
|
||||
adminModel: settings?.adminAiModel ?? null,
|
||||
};
|
||||
|
||||
const chain = ocr.chain;
|
||||
if (chain.length === 0) {
|
||||
const resolvedChain = ocr.chain;
|
||||
if (resolvedChain.length === 0) {
|
||||
// Mirror the Coach: fall back to the legacy single-provider resolution and
|
||||
// tag it as the admin-managed entry the consent gate recognises.
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type !== "none") {
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the caller's reorder (document class demotes external providers below
|
||||
// local) before picking; labs passes no reorder → identity.
|
||||
const chain = options?.reorder
|
||||
? options.reorder(resolvedChain)
|
||||
: resolvedChain;
|
||||
|
||||
for (const entry of chain) {
|
||||
const providerType = entry.providerType as VisionProviderType;
|
||||
const model = modelForEntry(entry.providerType, ctx);
|
||||
@@ -119,19 +141,25 @@ export async function resolveVisionProvider(
|
||||
* so this returns the whole chain plus the first entry to drive the structuring
|
||||
* call. Returns `null` when nothing is configured.
|
||||
*/
|
||||
export async function resolveTextProvider(userId: string): Promise<{
|
||||
export async function resolveTextProvider(
|
||||
userId: string,
|
||||
options?: ResolveProviderOptions,
|
||||
): Promise<{
|
||||
chain: ProviderChainResolved[];
|
||||
pick: { entry: ProviderChainResolved; providerType: string } | null;
|
||||
}> {
|
||||
// v1.22 (#90) — use the dedicated document-scan provider when enabled, else
|
||||
// the main chain (the text-mode structuring pass needs no vision).
|
||||
const chain = (await resolveOcrProviderChain(userId)).chain;
|
||||
if (chain.length === 0) {
|
||||
const resolvedChain = (await resolveOcrProviderChain(userId)).chain;
|
||||
if (resolvedChain.length === 0) {
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type !== "none") {
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
resolvedChain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
}
|
||||
const chain = options?.reorder
|
||||
? options.reorder(resolvedChain)
|
||||
: resolvedChain;
|
||||
const entry = chain[0];
|
||||
return {
|
||||
chain,
|
||||
@@ -147,8 +175,12 @@ export async function resolveTextProvider(userId: string): Promise<{
|
||||
*/
|
||||
export async function resolveOcrCapability(
|
||||
userId: string,
|
||||
options?: ResolveProviderOptions,
|
||||
): Promise<OcrCapabilityDto> {
|
||||
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(userId);
|
||||
const { chain, pick, localOcrEnabled } = await resolveVisionProvider(
|
||||
userId,
|
||||
options,
|
||||
);
|
||||
|
||||
// Prefer the native vision path whenever it is available — it is more
|
||||
// accurate and the client does not download the OCR WASM.
|
||||
|
||||
@@ -334,6 +334,38 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/capability": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
summary: "Probe document-AI availability + egress",
|
||||
description:
|
||||
'Cheap probe (no provider call) the vault uses to decide the AI transport and whether to warn before a read leaves the machine. Resolved over the DOCUMENT provider order (local-first, ChatGPT-subscription OAuth last), so `mode` / `pdfSupported` / `egress` match exactly what the document AI routes do. `egress` is vendor-blind: `"local"` (a self-hosted model — the document never leaves the operator\'s machine) or `"external"` (a third-party AI service). The vault shows a per-egress notice before any external document read; sending a document to any external provider also requires an active AI-consent receipt.',
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Document-AI capability flags + egress class.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z
|
||||
.object({
|
||||
available: z.boolean(),
|
||||
mode: z.enum(["vision", "text"]).nullable(),
|
||||
reason: z
|
||||
.enum(["no-provider", "enable-local-ocr"])
|
||||
.nullable(),
|
||||
pdfSupported: z.boolean(),
|
||||
egress: z.enum(["local", "external"]).nullable(),
|
||||
})
|
||||
.meta({ id: "DocumentAiCapability" }),
|
||||
"DocumentAiCapabilityEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/usage": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
|
||||
@@ -58,4 +58,12 @@ export const documentKeys = {
|
||||
* delete so the quota bar tracks reality.
|
||||
*/
|
||||
inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const,
|
||||
/**
|
||||
* Document-scoped AI capability probe
|
||||
* (`GET /api/documents/inbound/capability`). Distinct from the labs
|
||||
* `ocrCapability` key because it resolves over the DOCUMENT provider order
|
||||
* (local-first) and carries the per-egress class the vault notice reads.
|
||||
*/
|
||||
inboundDocumentAiCapability: () =>
|
||||
["documents", "inbound", "ai-capability"] as const,
|
||||
};
|
||||
@@ -594,6 +594,30 @@ export interface DocumentUsageDto {
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a document read egresses, vendor-blind. */
|
||||
export type DocumentEgressClass = "local" | "external";
|
||||
|
||||
/**
|
||||
* The document-scoped AI capability probe response
|
||||
* (`GET /api/documents/inbound/capability`). Resolved over the DOCUMENT
|
||||
* provider order (local-first, codex last), so `mode` / `pdfSupported` /
|
||||
* `egress` match exactly what the document AI routes will do. `egress` drives
|
||||
* the vault's per-egress "this leaves your machine to a third-party AI" notice.
|
||||
*/
|
||||
export interface DocumentAiCapabilityDto {
|
||||
available: boolean;
|
||||
mode: "vision" | "text" | null;
|
||||
reason: "no-provider" | "enable-local-ocr" | null;
|
||||
pdfSupported: boolean;
|
||||
/**
|
||||
* Where a document read will egress with the current provider order:
|
||||
* - "local": stays on the operator's machine (self-hosted model).
|
||||
* - "external": leaves the machine to a third-party AI service.
|
||||
* - null: no read is available (see `reason`).
|
||||
*/
|
||||
egress: DocumentEgressClass | null;
|
||||
}
|
||||
|
||||
// ─── AI assist / summary / index (Document vault P2) ────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user