mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(documents): route vault AI surfaces through document provider order
Repoint suggest/summary/extract/index/reindex routes and the content-index backfill job to the document-scoped resolvers (local-first, codex last) and the pick-based document-egress consent gate. Labs OCR and Coach/insights untouched. Co-Authored-By: Marc-André Bombeck
This commit is contained in:
1 parent
f7ad2339ae
commit
8e5f5f04ae
6 files changed
+79
-41
No files matched your search
@@ -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}`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in new issue
Block a user