From b36c7b6697fb2609603ec816efe1f843e82ca031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 09:06:22 +0200 Subject: [PATCH] feat(insights): serve status notes from the new table, only while available Every status note reader and writer moves to InsightStatusCache: the generators, the unchanged-data gates, the stale-while-revalidate read, the ingest debounce, the previous-note prompt block and the medication-compliance card with its per-medication lines. A stall now opens a negative-cache window next to the note instead of a stub row that shadowed it. Stored model text is never served while its capability is unavailable, whatever the reason: the status readers and gates check statusText, the period narrative hides a model-written row while periodNarrative is unavailable (the deterministic narrative is data and always shown), and the daily digest drops reaction lines while reactionLines is unavailable. A read-only miss now reports why (`unavailable` with the capability's reason) and keeps hasProvider meaning provider presence, so a withdrawn consent no longer reads as "no provider". runStatusCompletion takes the capability as a required argument and re-checks it before the provider chain is resolved, so a job queued before a switch flipped still stops at the wire. The comprehensive briefing re-checks briefing the same way. hasUsableStatusProvider is the one presence probe. Refs #975 --- scripts/seed-demo.ts | 54 +- src/__tests__/i18n-reverse-coverage.test.ts | 2 +- src/lib/ai/capabilities/gate.ts | 19 + src/lib/ai/coach/conversation-summary.ts | 2 +- src/lib/ai/coach/facts.ts | 2 +- src/lib/ai/coach/plans.ts | 2 +- src/lib/daily/load-digest.ts | 28 +- .../__tests__/biomarker-status.test.ts | 82 +- .../__tests__/blood-pressure-status.test.ts | 95 ++- src/lib/insights/__tests__/bmi-status.test.ts | 121 ++- ...ensive-briefing-grounding-disposal.test.ts | 9 + .../comprehensive-briefing-reroll.test.ts | 9 + .../comprehensive-budget-refusal.test.ts | 9 + .../comprehensive-generate-force.test.ts | 66 ++ .../comprehensive-json-retry.test.ts | 9 + .../comprehensive-privacy-flip.test.ts | 9 + .../comprehensive-timeout-honored.test.ts | 9 + .../comprehensive-warm-timeout.test.ts | 9 + .../insights/__tests__/general-status.test.ts | 95 ++- .../invalidate-status-insights.test.ts | 106 ++- .../__tests__/locale-pipeline.test.ts | 39 +- .../medication-compliance-status.test.ts | 131 ++- src/lib/insights/__tests__/memory.test.ts | 108 ++- .../insights/__tests__/metric-status.test.ts | 59 +- .../insights/__tests__/mood-status.test.ts | 91 +-- .../insights/__tests__/payload-budget.test.ts | 32 +- .../__tests__/pulse-status-timeout.test.ts | 74 +- .../insights/__tests__/pulse-status.test.ts | 92 ++- .../insights/__tests__/status-batch.test.ts | 45 +- .../__tests__/status-cache-delegated.test.ts | 42 +- .../insights/__tests__/status-cache.test.ts | 608 +++++++++----- .../__tests__/status-note-fixtures.ts | 105 +++ .../__tests__/status-provider-budget.test.ts | 49 +- .../insights/__tests__/weight-status.test.ts | 215 +++-- src/lib/insights/biomarker-status.ts | 10 +- src/lib/insights/blood-pressure-status.ts | 12 +- src/lib/insights/bmi-status.ts | 13 +- src/lib/insights/comprehensive-generate.ts | 24 + .../insights/derived/derived-assessment-ai.ts | 6 +- src/lib/insights/general-status.ts | 12 +- .../insights/medication-compliance-status.ts | 176 ++-- src/lib/insights/memory.ts | 62 +- src/lib/insights/metric-status.ts | 14 +- src/lib/insights/mood-status.ts | 12 +- .../period-narrative-generate.test.ts | 146 +++- .../narrative/period-narrative-generate.ts | 47 +- src/lib/insights/pulse-status.ts | 12 +- src/lib/insights/status-batch.ts | 20 +- src/lib/insights/status-cache.ts | 764 ++++++++++-------- src/lib/insights/status-card-generation.ts | 4 +- src/lib/insights/status-invalidation.ts | 70 +- src/lib/insights/status-provider.ts | 98 +-- src/lib/insights/status-shared.ts | 72 +- src/lib/insights/timeout-fallback.ts | 35 +- src/lib/insights/weight-status.ts | 12 +- .../workout-insight-generate.test.ts | 80 +- .../workout-insight-no-backfill-spend.test.ts | 11 + src/lib/jobs/workout-insight-generate.ts | 24 +- src/lib/workouts/insight-gates.ts | 3 +- .../insights-status-real-text.test.ts | 77 +- 60 files changed, 2574 insertions(+), 1669 deletions(-) create mode 100644 src/lib/insights/__tests__/status-note-fixtures.ts diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index ed24d0515..33167d40a 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -211,6 +211,7 @@ async function seed() { await client.query("DELETE FROM coach_conversations"); await client.query("DELETE FROM consent_receipts"); await client.query("DELETE FROM insight_narratives"); + await client.query("DELETE FROM insight_status_caches"); await client.query("DELETE FROM illness_symptom_links"); await client.query("DELETE FROM illness_day_logs"); await client.query("DELETE FROM illness_episodes"); @@ -1826,11 +1827,10 @@ async function seed() { console.log("Creating baked AI insight texts..."); const todayKey = berlinDayKey(); - // A real-looking provider/model pair so the read path treats the row as a - // genuine assessment (NOT a `model: "timeout-stub"`, which the cache-read - // rejects). The text is what renders; provider/model are provenance only. + // A real-looking provider tag for the rows that record one (the cached + // briefing, the narratives). The text is what renders; the tag is + // provenance only. const bakedProvider = "anthropic"; - const bakedModel = "claude-3-5-sonnet"; // ── Comprehensive insight + daily briefing ── // Stored on users.insights_cached_text (JSON). The GET read parses it and @@ -2037,13 +2037,12 @@ async function seed() { ); // ── Per-metric status cards ── - // Each card is an audit_logs row keyed `insights.-status.` - // whose details JSON is { dateKey, locale, text, providerType, model, - // tokensUsed, snapshotHash }. The read serves a row only when its dateKey - // equals today's Berlin key and the model is not the timeout-stub - // sentinel. We stamp today's key + a real model name so every card reads - // as a current assessment. `text` is the rendered field. 2-4 sentences - // each, grounded in the seeded data, in the base-system advisor voice. + // Each card is an insight_status_caches row keyed (user, metric, locale), + // the note encrypted with the app's codec. The read serves a note only + // when its date_key equals today's key, so every card is stamped with + // today's key and reads as a current assessment. The medication card also + // carries its (empty) per-medication list. 2-4 sentences each, grounded in + // the seeded data, in the base-system advisor voice. const statusCards: Array<{ scope: string; text: string }> = [ { scope: "blood-pressure", @@ -2108,23 +2107,30 @@ async function seed() { ]; for (const card of statusCards) { - const action = `insights.${card.scope}-status.en`; await client.query( - `INSERT INTO audit_logs (id, user_id, action, details, created_at) - VALUES ($1, $2, $3, $4, NOW())`, + `INSERT INTO insight_status_caches + (id, user_id, metric, locale, text_encrypted, items_encrypted, + snapshot_hash, date_key, generated_at, created_at, updated_at) + VALUES ($1, $2, $3, 'en', $4, $5, $6, $7, NOW(), NOW(), NOW()) + ON CONFLICT (user_id, metric, locale) DO UPDATE SET + text_encrypted = EXCLUDED.text_encrypted, + items_encrypted = EXCLUDED.items_encrypted, + snapshot_hash = EXCLUDED.snapshot_hash, + date_key = EXCLUDED.date_key, + generated_at = EXCLUDED.generated_at, + retry_at = NULL, + negative_reason = NULL, + updated_at = NOW()`, [ cuid(), userId, - action, - JSON.stringify({ - dateKey: todayKey, - locale: "en", - text: card.text, - providerType: bakedProvider, - model: bakedModel, - tokensUsed: null, - snapshotHash: `demo-baked-${card.scope}`, - }), + card.scope, + Buffer.from(encryptToBytes(card.text)), + card.scope === "medication-compliance" + ? Buffer.from(encryptToBytes("[]")) + : null, + `demo-baked-${card.scope}`, + todayKey, ], ); } diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index cb5a0d316..759d4b43c 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -74,7 +74,7 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ */ const EXEMPT_TEMPLATES: Record = { "insights.${scope}-status.${locale}": - "auditLog action name for the per-metric status cache (statusCacheAction), not an i18n key", + "the per-metric status note name (statusCacheAction), not an i18n key", "insights.${scope}.${locale}": "auditLog action name for the previous-insight memory read (getPreviousInsightContext), not an i18n key", "apiErrors.${errorCode}": diff --git a/src/lib/ai/capabilities/gate.ts b/src/lib/ai/capabilities/gate.ts index 254a4d794..3c7df11d9 100644 --- a/src/lib/ai/capabilities/gate.ts +++ b/src/lib/ai/capabilities/gate.ts @@ -150,3 +150,22 @@ export async function aiCapabilityForJob( return resolveAiCapability(key, null); } } + +/** + * One capability for one record, from wherever the caller runs. Inside an + * authenticated request it is the request's view (masked to the active grant, + * memoised with every other capability read of the request); outside one it + * is the worker's view under the worker's own authority. + * + * For code that serves stored model text on both paths: a status note read on + * a page visit and the same read inside the nightly batch must answer the same + * question without the caller knowing which path it is on. + */ +export async function aiCapabilityForRecord( + recordId: string, + key: AiCapabilityKey, +): Promise { + return getEvent()?.getAuth()?.user_id + ? getAiCapability(key, { recordId }) + : aiCapabilityForJob(recordId, key); +} diff --git a/src/lib/ai/coach/conversation-summary.ts b/src/lib/ai/coach/conversation-summary.ts index 41d7306b5..9bdc03b4a 100644 --- a/src/lib/ai/coach/conversation-summary.ts +++ b/src/lib/ai/coach/conversation-summary.ts @@ -193,7 +193,7 @@ export async function refreshConversationSummary( const completion: StatusProviderResult = await runCompletion({ userId, cacheAction: "coach.summary", - consentSurface: "coach", + capability: "coach", systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN, userPrompt: buildSummaryUserPrompt(priorSummary, foldedTurns, locale), temperature: 0.3, diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts index 8d3921c19..c3fb74daa 100644 --- a/src/lib/ai/coach/facts.ts +++ b/src/lib/ai/coach/facts.ts @@ -310,7 +310,7 @@ export async function extractAndStoreFacts( const result = await runCompletion({ userId, cacheAction: "coach.facts", - consentSurface: "coach", + capability: "coach", systemPrompt: extractionSystemPrompt(opts?.locale), userPrompt: buildUserPrompt(turns, activeTexts), temperature: 0.2, diff --git a/src/lib/ai/coach/plans.ts b/src/lib/ai/coach/plans.ts index cade32d18..d418a587f 100644 --- a/src/lib/ai/coach/plans.ts +++ b/src/lib/ai/coach/plans.ts @@ -335,7 +335,7 @@ export async function extractAndStorePlanProposals( const result = await runCompletion({ userId, cacheAction: "coach.plans", - consentSurface: "coach", + capability: "coach", systemPrompt: extractionSystemPrompt(opts?.locale), userPrompt: buildUserPrompt(turns, existingSignatures), temperature: 0.2, diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index a7e14b030..79d68db23 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -21,6 +21,7 @@ import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import { userDayKey } from "@/lib/tz/format"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -305,13 +306,16 @@ function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan { * marker itself — which is what actually drives the "just in" chip — survives * a lost sentence intact. Same discipline as `toCoachPlanCandidate` above. */ -function toDigestArrival(row: { - kind: string; - occurredAt: Date; - arrivedAt: Date; - lineEncrypted: Uint8Array | null; - generatedAt: Date | null; -}): DailyDigestArrival | null { +function toDigestArrival( + row: { + kind: string; + occurredAt: Date; + arrivedAt: Date; + lineEncrypted: Uint8Array | null; + generatedAt: Date | null; + }, + linesServable: boolean, +): DailyDigestArrival | null { // A kind this build does not know about (a row written by a newer version) // is dropped rather than widened — the DTO's kind union is closed. if (!isArrivalKind(row.kind)) return null; @@ -319,7 +323,9 @@ function toDigestArrival(row: { let line: string | null = null; // The ciphertext rides only once the generation actually COMMITTED. A row // mid-generation carries no `generatedAt`, and its line must not surface. - if (row.generatedAt !== null && row.lineEncrypted !== null) { + // A model-written line is never served while `reactionLines` is + // unavailable for the record, whatever the reason; the marker still is. + if (linesServable && row.generatedAt !== null && row.lineEncrypted !== null) { try { line = decryptFromBytes(row.lineEncrypted); } catch { @@ -366,6 +372,7 @@ export async function loadDailyDigest( ); const [ + linesServable, { body: snapshot, locale }, modules, syncRows, @@ -375,6 +382,9 @@ export async function loadDailyDigest( arrivalRows, nextVisitRow, ] = await Promise.all([ + aiCapabilityForRecord(user.id, "reactionLines").then( + (capability) => capability.available, + ), readDashboardSnapshotCached(user, undefined, { locale: options.locale }), resolveModuleMap(user.id), prisma.integrationStatus.findMany({ @@ -637,7 +647,7 @@ export async function loadDailyDigest( todayLocalDate, dismissedItemKeys, arrivals: arrivalRows - .map(toDigestArrival) + .map((row) => toDigestArrival(row, linesServable)) .filter((a): a is DailyDigestArrival => a !== null), }, t, diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts index da9f3c7f7..70c8acdef 100644 --- a/src/lib/insights/__tests__/biomarker-status.test.ts +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -4,7 +4,11 @@ vi.mock("@/lib/db", () => ({ prisma: { biomarker: { findFirst: vi.fn() }, labResult: { findMany: vi.fn(), count: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.30.3 (QA F5) — `resolveUserTimezone` reads this directly. user: { findUnique: vi.fn() }, }, @@ -12,8 +16,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); import { prisma } from "@/lib/db"; @@ -26,6 +43,7 @@ import { biomarkerStatusScope, } from "../biomarker-status"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; +import { noteRow, writtenNotes } from "./status-note-fixtures"; const MARKER = { id: "bm-1", @@ -74,10 +92,12 @@ function inputHashFor(reading: { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(MARKER as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date("2026-06-20T09:00:00.000Z"), - } as never); // Default: no stored timezone → resolveUserTimezone falls to the // Europe/Berlin server default, matching every other fixture's assumption. vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); @@ -85,7 +105,6 @@ beforeEach(() => { describe("generateBiomarkerStatus — empty-data guard", () => { it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); const result = await generateBiomarkerStatus({ @@ -119,15 +138,13 @@ describe("generateBiomarkerStatus — empty-data guard", () => { describe("generateBiomarkerStatus — cache read", () => { it("serves today's cached text without calling the provider", async () => { const todayKey = toBerlinDayKey(new Date()); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", text: "Your LDL is steady.", - model: "x", - }), - } as never); + generatedAt: new Date(), + }) as never, + ); const result = await generateBiomarkerStatus({ biomarkerId: MARKER.id, @@ -146,7 +163,6 @@ describe("generateBiomarkerStatus — cache read", () => { describe("generateBiomarkerStatus — generation path", () => { it("builds a snapshot, runs the completion, and persists with an inputHash", async () => { const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); @@ -175,13 +191,9 @@ describe("generateBiomarkerStatus — generation path", () => { expect(snapshot.latest.value).toBe(95); // Persisted under the biomarker scope cache action, carrying the inputHash. - const createCall = vi - .mocked(prisma.auditLog.create) - .mock.calls.at(-1)![0] as { - data: { action: string; details: string }; - }; - expect(createCall.data.action).toBe("insights.biomarker:bm-1-status.en"); - const persisted = JSON.parse(createCall.data.details); + const persisted = writtenNotes(prisma.insightStatusCache.upsert).at(-1)!; + expect(persisted.metric).toBe("biomarker:bm-1"); + expect(persisted.locale).toBe("en"); expect(persisted.inputHash).toBe(inputHashFor(reading)); }); }); @@ -200,7 +212,6 @@ describe("generateBiomarkerStatus — prompt-injection fence", () => { unit: hostileUnit, } as never); const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); @@ -253,7 +264,6 @@ describe("generateBiomarkerStatus — per-user tz (QA F5)", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "America/New_York", } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); @@ -279,16 +289,14 @@ describe("generateBiomarkerStatus — input gate (regenerate only on a new readi // A prior assessment from an earlier day whose inputHash matches the // current latest reading — readFreshStatusText misses on the day key, but // the input gate matches the fingerprint. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2000-01-01", - locale: "en", text: "Stable LDL from a previous reading.", - model: "x", + generatedAt: new Date(), inputHash: inputHashFor(reading), - }), - } as never); + }) as never, + ); vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); @@ -312,16 +320,14 @@ describe("generateBiomarkerStatus — input gate (regenerate only on a new readi }; // The cached row's inputHash is for the OLD reading; the live latest is the // new reading, so the fingerprint differs and the gate misses. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2000-01-01", - locale: "en", text: "Stale assessment.", - model: "x", + generatedAt: new Date(), inputHash: inputHashFor(oldReading), - }), - } as never); + }) as never, + ); vi.mocked(prisma.labResult.findMany).mockResolvedValue([ newReading, ] as never); diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 437c065da..8443734cf 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, measurementRollup: { findMany: vi.fn() }, medication: { findMany: vi.fn() }, @@ -14,8 +18,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -31,6 +48,7 @@ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { getMedicationCategories } from "@/lib/medication-category"; import { generateBloodPressureStatusForUser } from "../blood-pressure-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -54,6 +72,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(getMedicationCategories).mockResolvedValue({}); // Cold rollup tier: the BP channels fold monthly/yearly from the // full-history `measurement.findMany` fallback on a tier miss. @@ -85,16 +108,12 @@ describe("generateBloodPressureStatusForUser — graded payload", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -138,7 +157,6 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists", vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() }, @@ -148,9 +166,6 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists", [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -164,23 +179,20 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists", expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateBloodPressureStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -188,16 +200,15 @@ describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Blood pressure assessment fallback…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now }, @@ -207,9 +218,6 @@ describe("generateBloodPressureStatusForUser — cache-read skips a stub", () => [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh BP assessment."}'); @@ -228,7 +236,6 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: new Date() }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: new Date() }, @@ -238,9 +245,6 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Systolic averaged 132. metric:BLOOD_PRESSURE_SYS Diastolic stable. metric:BLOOD_PRESSURE_DIA"}', @@ -252,12 +256,9 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); @@ -301,7 +302,6 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation" vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now }, { type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now }, @@ -314,9 +314,6 @@ describe("generateBloodPressureStatusForUser — no rate without an expectation" [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(getMedicationCategories).mockResolvedValue({ "med-empty": "BLOOD_PRESSURE", "med-real": "BLOOD_PRESSURE", diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index d0cc8da06..44a73647a 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.18.11 (P6) — the input gate probes salient inputs via groupBy. measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { findMany: vi.fn() }, @@ -12,8 +16,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -24,6 +41,7 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateBmiStatusForUser } from "../bmi-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -48,6 +66,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); // Cold rollup tier: the BMI graded series scales the WEIGHT tier, which // folds from the full-history `measurement.findMany` fallback on a miss. vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); @@ -71,11 +94,7 @@ describe("generateBmiStatusForUser — graded payload", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -119,15 +138,13 @@ describe("generateBmiStatusForUser — per-user tz (QA F5)", () => { } as never); // A cached row keyed to the LA calendar day should be served as a // fresh hit even though it is already "tomorrow" in Berlin. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: laToday, - locale: "en", text: "LA-anchored cached BMI text.", - model: "x", - }), - } as never); + generatedAt: new Date(), + }) as never, + ); const result = await generateBmiStatusForUser("user-la", { locale: "en", @@ -151,13 +168,9 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 80, measuredAt: new Date() }, ] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -172,25 +185,21 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => { expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); it("serves the fallback without writing a cache row on provider error", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 80, measuredAt: new Date() }, ] as never); @@ -203,23 +212,20 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => { expect(result.text).toBeTruthy(); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateBmiStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateBmiStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -228,22 +234,18 @@ describe("generateBmiStatusForUser — cache-read skips a stub", () => { heightCm: 175, } as never); // The most recent cached row is a stub keyed to today. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Generic stub fallback.", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 80, measuredAt: now }, ] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh real assessment."}'); @@ -261,13 +263,9 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () = vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: 175, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 80, measuredAt: new Date() }, ] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"BMI sits in the green band. metric:WEIGHT and steady."}', @@ -277,11 +275,8 @@ describe("generateBmiStatusForUser — token-leak hardening (v1.4.27 F16)", () = expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 53a38a769..39a6ffeb1 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -40,6 +40,15 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 7517e6f66..cf4c94793 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -40,6 +40,15 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index b000afac4..1a50d530f 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -69,6 +69,15 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 8ab32fb81..180a8a612 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -24,6 +24,7 @@ const resolveProviderChain = vi.fn(); const resolveProvider = vi.fn(); const runRawCompletionWithFallback = vi.fn(); const extractFeatures = vi.fn(); +const aiCapabilityForRecord = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -49,6 +50,9 @@ vi.mock("@/lib/db", () => ({ }, }, })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), @@ -108,6 +112,68 @@ beforeEach(() => { resolveProvider.mockResolvedValue({ type: "none" }); extractFeatures.mockResolvedValue(FEATURES); userUpdate.mockResolvedValue({}); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); +}); + +describe("generateComprehensiveInsight — the briefing capability at the wire", () => { + const STALE_USER = { + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + insightsSnapshotHash: null, + }; + + it.each([ + ["no_provider", "no-provider"], + ["consent_required", "no-consent"], + ["user_disabled", "unavailable"], + ["operator_disabled", "unavailable"], + ] as const)( + "skips with %s → %s and never resolves the chain", + async (reason, skipped) => { + findUnique.mockResolvedValue(STALE_USER); + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + + const outcome = await generateComprehensiveInsight("u1", { + locale: "de", + force: true, + }); + + expect(outcome).toEqual({ status: "skipped", reason: skipped }); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "briefing"); + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(resolveProvider).not.toHaveBeenCalled(); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(extractFeatures).not.toHaveBeenCalled(); + }, + ); + + it("still serves a fresh cache without asking the capability (cache short-circuit comes first)", async () => { + findUnique.mockResolvedValue({ + ...STALE_USER, + insightsCachedAt: new Date(Date.now() - 60 * 60 * 1000), + insightsCachedText: "{}", + }); + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "cached" }); + expect(aiCapabilityForRecord).not.toHaveBeenCalled(); + }); }); describe("generateComprehensiveInsight — cache short-circuit", () => { diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index f64cfdc86..a3f42c45a 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -38,6 +38,15 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 4ee3bc274..4b70cab43 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -37,6 +37,15 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 73bc1fc6c..5d9fdbcee 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -42,6 +42,15 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn(), create: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index 24ee58f6d..15304c0d1 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -44,6 +44,15 @@ vi.mock("@/lib/db", () => ({ auditLog: { deleteMany: vi.fn() }, }, })); +// The `briefing` capability is available in these fixtures; the refusal +// path has its own test in comprehensive-generate-force.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), resolveProvider: (...a: unknown[]) => resolveProvider(...a), diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index 6d004dfbe..e5fd0f0c8 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, @@ -12,8 +16,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -24,6 +41,7 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateGeneralStatusForUser } from "../general-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -47,6 +65,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); }); describe("generateGeneralStatusForUser — graded payload", () => { @@ -69,7 +92,6 @@ describe("generateGeneralStatusForUser — graded payload", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue( weightRecords as never, ); @@ -77,9 +99,6 @@ describe("generateGeneralStatusForUser — graded payload", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -113,7 +132,6 @@ describe("generateGeneralStatusForUser — graded payload", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 80, measuredAt: now }, ] as never); @@ -121,9 +139,6 @@ describe("generateGeneralStatusForUser — graded payload", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -141,7 +156,6 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () => vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, ] as never); @@ -149,9 +163,6 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () => [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -165,23 +176,20 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () => expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateGeneralStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateGeneralStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -189,16 +197,15 @@ describe("generateGeneralStatusForUser — cache-read skips a stub", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "General status fallback…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: now }, ] as never); @@ -206,9 +213,6 @@ describe("generateGeneralStatusForUser — cache-read skips a stub", () => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh general assessment."}'); @@ -227,7 +231,6 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, ] as never); @@ -235,9 +238,6 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Weight trended down. metric:WEIGHT BP stable. metric:BLOOD_PRESSURE_SYS"}', @@ -249,11 +249,8 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)", expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index fed621785..8ed1626a3 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -15,7 +15,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const deleteMany = vi.fn(); -const auditFindMany = vi.fn(); +const noteFindMany = vi.fn(); +const aiCapabilityForRecord = vi.fn(); const enqueueStatusGeneration = vi.fn(); const userFindUnique = vi.fn(); const measurementFindMany = vi.fn(); @@ -23,9 +24,9 @@ const measurementGroupBy = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { + insightStatusCache: { deleteMany: (...a: unknown[]) => deleteMany(...a), - findMany: (...a: unknown[]) => auditFindMany(...a), + findMany: (...a: unknown[]) => noteFindMany(...a), }, user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, measurement: { @@ -41,6 +42,21 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), + aiCapabilityForJob: vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + })), +})); + +const UNAVAILABLE = { + available: false, + reason: "user_disabled", + onDeviceAllowed: false, +}; + import { enqueueStatusRefillForUser, invalidateStatusInsightsForTypes, @@ -51,7 +67,12 @@ beforeEach(() => { deleteMany.mockResolvedValue({ count: 0 }); // Default: no recently-warmed cache rows, so every dirtied scope is stale // and refreshes — the pre-debounce contract the bulk of these tests pin. - auditFindMany.mockResolvedValue([]); + noteFindMany.mockResolvedValue([]); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); enqueueStatusGeneration.mockResolvedValue(undefined); userFindUnique.mockResolvedValue({ locale: "de" }); measurementFindMany.mockResolvedValue([]); @@ -138,7 +159,15 @@ describe("invalidateStatusInsightsForTypes", () => { it("is a no-op for an empty type set (no DB call)", async () => { await invalidateStatusInsightsForTypes("u1", []); - expect(auditFindMany).not.toHaveBeenCalled(); + expect(noteFindMany).not.toHaveBeenCalled(); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + }); + + it("enqueues nothing while statusText is unavailable for the record", async () => { + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT", "PULSE"]); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); + expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -201,18 +230,15 @@ describe("invalidateStatusInsightsForTypes", () => { }); describe("ingest-invalidation debounce (v1.9.0; a 1 h minimum gap since v1.16.8)", () => { - /** Build a recent (within-window) real assessment cache row. */ + /** A recent (within-window) stored note, as the probe selects it. */ function freshRow(scope: string, locale = "de") { - return { - action: `insights.${scope}-status.${locale}`, - details: JSON.stringify({ text: "fresh assessment", model: "gpt" }), - }; + return { metric: scope, locale }; } it("skips a scope whose assessment was warmed within the gap", async () => { // `general` was regenerated within the gap; a fresh WEIGHT sample // must not re-enqueue it. weight + bmi are still stale, so they refresh. - auditFindMany.mockResolvedValue([freshRow("general")]); + noteFindMany.mockResolvedValue([freshRow("general")]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); expect(enqueuedScopes()).toEqual(["bmi", "weight"]); }); @@ -224,13 +250,13 @@ describe("invalidateStatusInsightsForTypes", () => { // hash gate, which makes a no-change run a free timestamp refresh — // this is what lets same-day data be narrated same-day without // reopening the per-batch regeneration storm. - auditFindMany.mockResolvedValue([]); + noteFindMany.mockResolvedValue([]); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); it("is a complete no-op (no enqueue) when every scope is fresh", async () => { - auditFindMany.mockResolvedValue([ + noteFindMany.mockResolvedValue([ freshRow("weight"), freshRow("bmi"), freshRow("general"), @@ -239,16 +265,14 @@ describe("invalidateStatusInsightsForTypes", () => { expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); - it("treats a recent timeout stub as not-fresh and still refreshes the scope", async () => { - // A stub carries no real assessment, so a scope that recently stalled - // must retry rather than be debounced into staying cold. - auditFindMany.mockResolvedValue([ - { - action: "insights.general-status.de", - details: JSON.stringify({ model: "timeout-stub", text: "stub" }), - }, - ]); + it("counts only a stored note as fresh, so a recently stalled scope still refreshes", async () => { + // A row that carries only a negative-cache window has no note, so a + // scope that recently stalled must retry rather than be debounced into + // staying cold. The probe asks the database for noted rows only; with + // none returned, every dirtied scope refreshes. await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + const where = noteFindMany.mock.calls[0][0].where; + expect(where.textEncrypted).toEqual({ not: null }); expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); }); @@ -256,23 +280,23 @@ describe("invalidateStatusInsightsForTypes", () => { userFindUnique.mockResolvedValue({ locale: "en" }); const before = Date.now(); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - const where = auditFindMany.mock.calls[0][0].where; + const where = noteFindMany.mock.calls[0][0].where; expect(where.userId).toBe("u1"); - // Only the en cache actions for the dirtied scopes are probed. - expect(new Set(where.action.in)).toEqual( - new Set([ - "insights.weight-status.en", - "insights.bmi-status.en", - "insights.general-status.en", - ]), - ); + // Only the en notes for the dirtied scopes are probed. + expect( + new Set( + (where.OR as { metric: string; locale: string }[]).map( + (k) => `${k.metric}.${k.locale}`, + ), + ), + ).toEqual(new Set(["weight.en", "bmi.en", "general.en"])); // The recency floor is ONE hour in the past. The 6 h wall this // started as kept same-day data from being narrated same-day (the // nightly warm restarted the window, so a morning reading stayed // un-narrated until tomorrow); the 1 h gap only bounds worker-run // frequency, while the worker's content-hash gate keeps an // unchanged re-run at zero LLM calls. - const cutoff = (where.createdAt.gte as Date).getTime(); + const cutoff = (where.generatedAt.gte as Date).getTime(); const oneHour = 60 * 60 * 1000; expect(before - cutoff).toBeGreaterThanOrEqual(oneHour - 5_000); expect(before - cutoff).toBeLessThanOrEqual(oneHour + 60_000); @@ -323,17 +347,21 @@ describe("enqueueStatusRefillForUser", () => { it("bypasses the ingest debounce — an explicit regenerate refills even freshly-warmed scopes", async () => { // Every scope reads as freshly warmed; the refill must NOT consult the // freshness probe at all (the user is explicitly asking). - auditFindMany.mockResolvedValue([ - { - action: "insights.general-status.en", - details: JSON.stringify({ text: "fresh assessment", model: "gpt" }), - }, - ]); + noteFindMany.mockResolvedValue([{ metric: "general", locale: "en" }]); await enqueueStatusRefillForUser("u1", "en"); - expect(auditFindMany).not.toHaveBeenCalled(); + expect(noteFindMany).not.toHaveBeenCalled(); expect(enqueuedScopes()).toContain("general"); }); + it("refills nothing and returns 0 while statusText is unavailable", async () => { + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + measurementGroupBy.mockResolvedValue([{ type: "BLOOD_GLUCOSE" }]); + const count = await enqueueStatusRefillForUser("u1", "de"); + expect(count).toBe(0); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + expect(measurementGroupBy).not.toHaveBeenCalled(); + }); + it("still refills the specialised scopes when the generic-scope discovery read fails", async () => { measurementGroupBy.mockRejectedValue(new Error("pool exhausted")); const count = await enqueueStatusRefillForUser("u1", "de"); diff --git a/src/lib/insights/__tests__/locale-pipeline.test.ts b/src/lib/insights/__tests__/locale-pipeline.test.ts index 356539dc0..cb8452d11 100644 --- a/src/lib/insights/__tests__/locale-pipeline.test.ts +++ b/src/lib/insights/__tests__/locale-pipeline.test.ts @@ -70,7 +70,11 @@ describe("status cache keys", () => { vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, @@ -79,7 +83,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -104,6 +122,7 @@ vi.mock("@/lib/ai/prompts/general-status", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; +import { writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -117,10 +136,6 @@ beforeEach(() => { heightCm: null, insightsExcludeMetrics: [], } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); @@ -166,12 +181,10 @@ describe("a fr locale survives from the generator entry point to the prompt", () it("writes the fr assessment to the fr cache row, leaving the en row untouched", async () => { await generateGeneralStatusForUser("u-fr", { locale: "fr", force: true }); - const actions = vi - .mocked(prisma.auditLog.create) - .mock.calls.map( - (call) => (call[0] as { data: { action: string } }).data.action, - ); - expect(actions).toContain("insights.general-status.fr"); - expect(actions).not.toContain("insights.general-status.en"); + const keys = writtenNotes(prisma.insightStatusCache.upsert).map( + (note) => `${note.metric}.${note.locale}`, + ); + expect(keys).toContain("general.fr"); + expect(keys).not.toContain("general.en"); }); }); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index e480a3f65..7252318f9 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, }, @@ -10,8 +14,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -28,6 +45,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider"; import { getMedicationCategories } from "@/lib/medication-category"; import { getNoKeyMedicationComplianceStatusText } from "@/lib/insights/no-key-fallbacks"; import { generateMedicationComplianceStatusForUser } from "../medication-compliance-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -64,6 +82,11 @@ function medFixture(now: Date) { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(getMedicationCategories).mockResolvedValue({}); }); @@ -102,16 +125,12 @@ describe("generateMedicationComplianceStatusForUser — graded payload", () => { }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medication, ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( events as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK","medications":[]}', captured); @@ -140,16 +159,12 @@ describe("generateMedicationComplianceStatusForUser — graded payload", () => { describe("generateMedicationComplianceStatusForUser — timeout never persists", () => { it("serves the fallback summary without writing a cache row on timeout", async () => { const now = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -164,46 +179,39 @@ describe("generateMedicationComplianceStatusForUser — timeout never persists", expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateMedicationComplianceStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateMedicationComplianceStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", }).format(now); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Medication compliance fallback…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion( '{"summary":"Fresh compliance assessment.","medications":[]}', @@ -230,16 +238,12 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2 * stays null; the per-medication placeholder rows still render. */ function setupCard(now: Date) { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); } it("withholds a dose-change imperative instead of rendering the raw prose", async () => { @@ -257,13 +261,11 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2 expect(result.updatedAt).toBeNull(); // The per-medication placeholder rows still surface for the active med. expect(result.medications).toHaveLength(1); - // No cache row ever persisted the screened model text. - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ) as { summary?: string }; - expect(details.summary ?? "").not.toContain("10 mg"); + // No note ever stored the screened model text. + for (const note of upsertedNotes(prisma.insightStatusCache.upsert)) { + expect(note.text ?? "").not.toContain("10 mg"); } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); }); it("withholds a fabricated risk figure instead of rendering the raw prose", async () => { @@ -299,17 +301,12 @@ describe("generateMedicationComplianceStatusForUser — outbound screen (v1.32.2 ); expect(result.updatedAt).not.toBeNull(); expect(result.cached).toBe(false); - // The clean assessment was persisted (not the negative stub). - const persisted = vi - .mocked(prisma.auditLog.create) - .mock.calls.map( - (call) => - JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ) as { summary?: string }, - ) - .find((details) => (details.summary ?? "").includes("96%")); + // The clean assessment was stored as a note (not a negative window). + const persisted = writtenNotes(prisma.insightStatusCache.upsert).find( + (note) => (note.text ?? "").includes("96%"), + ); expect(persisted).toBeTruthy(); + expect(persisted!.negativeReason).toBeNull(); }); }); @@ -317,16 +314,12 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 it("strips metric: tokens out of the cached summary", async () => { const now = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ medFixture(now), ] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); // The compliance prompt returns only a `{ summary }` envelope; the // per-medication cards are placeholder text built server-side, never @@ -348,16 +341,11 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1 expect(result.medications).toHaveLength(1); expect(result.medications[0].medicationId).toBe("med-1"); expect(result.medications[0].text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { - summary: string; - medications: Array<{ text: string }>; - }; - expect(parsed.summary).not.toContain("metric:"); - expect(parsed.medications[0].text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); + const items = notes[0].items as Array<{ text: string }>; + expect(items[0].text).not.toContain("metric:"); }); }); @@ -396,7 +384,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec }; }); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ scheduled, scheduleless(now), @@ -404,9 +391,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( events as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK","medications":[]}', captured); @@ -424,7 +408,6 @@ describe("generateMedicationComplianceStatusForUser — no rate without an expec it("reports no active medications when the only one has no schedule", async () => { const now = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.medication.findMany).mockResolvedValue([ scheduleless(now), ] as never); diff --git a/src/lib/insights/__tests__/memory.test.ts b/src/lib/insights/__tests__/memory.test.ts index 1b1317078..bca6fcea9 100644 --- a/src/lib/insights/__tests__/memory.test.ts +++ b/src/lib/insights/__tests__/memory.test.ts @@ -2,7 +2,18 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn() }, + insightStatusCache: { findUnique: vi.fn() }, + }, +})); + +// A reversible stand-in for the at-rest codec; what this file pins is which +// stored note becomes prompt context, not AES-GCM. +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (plain: string) => new TextEncoder().encode(`enc:${plain}`), + decryptFromBytes: (buf: Uint8Array) => { + const text = new TextDecoder().decode(buf); + if (!text.startsWith("enc:")) throw new Error("unknown key id"); + return text.slice(4); }, })); @@ -12,75 +23,90 @@ import { getPreviousInsightContext, } from "../memory"; +function noteRow(text: string | null, generatedAt: Date | null) { + return { + textEncrypted: + text === null ? null : new TextEncoder().encode(`enc:${text}`), + itemsEncrypted: null, + inputHash: null, + snapshotHash: null, + dateKey: "2026-05-01", + generatedAt, + retryAt: null, + negativeReason: null, + }; +} + beforeEach(() => { - vi.mocked(prisma.auditLog.findFirst).mockReset(); + vi.mocked(prisma.insightStatusCache.findUnique).mockReset(); }); describe("getPreviousInsightContext", () => { - it("returns null when no audit row exists", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null); + it("returns null when no stored note exists", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce(null); const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); expect(ctx).toBeNull(); }); - it("returns null when the audit row's details cannot be parsed", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - createdAt: new Date(), - details: "", - } as never); + it("returns null when the row carries no note (only a negative window)", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow(null, null) as never, + ); const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); expect(ctx).toBeNull(); }); - it("extracts text from the JSON-shaped cache payload", async () => { + it("returns the stored note with its age", async () => { const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - createdAt: sevenDaysAgo, - details: JSON.stringify({ - dateKey: "2026-05-01", - text: "Your BP averaged 130/80 — high-normal.", - }), - } as never); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow("Your BP averaged 130/80 — high-normal.", sevenDaysAgo) as never, + ); const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); expect(ctx).not.toBeNull(); expect(ctx!.ageDays).toBe(7); expect(ctx!.text).toContain("130/80"); - }); - - it("falls back to raw audit details when not JSON", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - createdAt: new Date(Date.now() - 86_400_000), - details: "Plain text from a legacy cache row.", - } as never); - const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); - expect(ctx?.text).toBe("Plain text from a legacy cache row."); + expect(ctx!.generatedAt).toBe(sevenDaysAgo.toISOString()); }); it("caps the text length so a verbose snapshot cannot bloat the prompt", async () => { const big = "x".repeat(5000); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - createdAt: new Date(Date.now() - 86_400_000), - details: JSON.stringify({ text: big }), - } as never); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow(big, new Date(Date.now() - 86_400_000)) as never, + ); const ctx = await getPreviousInsightContext("u-1", "general-status", "en"); expect(ctx!.text.length).toBeLessThanOrEqual(1502); // 1500 + "…" expect(ctx!.text.endsWith("…")).toBe(true); }); it("filters by minAgeHours so today's earlier run isn't treated as 'previous'", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null); - await getPreviousInsightContext("u-1", "general-status", "en", 24); - const call = vi.mocked(prisma.auditLog.findFirst).mock.calls[0]?.[0]; - expect(call?.where?.action).toBe("insights.general-status.en"); - // The lt filter exists with a date in the past. It can't be more - // than 24h ago because we just constructed it; allow a 30s slack. - const lt = (call?.where?.createdAt as { lt: Date }).lt; - expect(Date.now() - lt.getTime()).toBeGreaterThan( - 24 * 60 * 60 * 1000 - 30_000, + // Written 23 h ago: inside a 24 h floor, so not "previous". + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow("Earlier today.", new Date(Date.now() - 23 * 3_600_000)) as never, ); - expect(Date.now() - lt.getTime()).toBeLessThan( - 24 * 60 * 60 * 1000 + 30_000, + expect( + await getPreviousInsightContext("u-1", "general-status", "en", 24), + ).toBeNull(); + const call = vi.mocked(prisma.insightStatusCache.findUnique).mock + .calls[0]?.[0] as { + where: { userId_metric_locale: Record }; + }; + expect(call.where.userId_metric_locale).toEqual({ + userId: "u-1", + metric: "general", + locale: "en", + }); + + // Written 25 h ago: past the floor, so it is the previous analysis. + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow("Yesterday.", new Date(Date.now() - 25 * 3_600_000)) as never, ); + const ctx = await getPreviousInsightContext( + "u-1", + "general-status", + "en", + 24, + ); + expect(ctx?.text).toBe("Yesterday."); }); }); diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index d930ac00f..668d8b961 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn(), count: vi.fn() }, measurementRollup: { findMany: vi.fn() }, }, @@ -11,8 +15,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -52,6 +69,7 @@ import { metricStatusScope, METRIC_STATUS_IDS, } from "../metric-status-registry"; +import { writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -78,6 +96,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); // resetAllMocks clears the module-mock default impls too — restore the // benign defaults so the relations fetch and the previous-context format @@ -90,14 +113,10 @@ beforeEach(() => { describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)", () => { it("feeds the snapshot the per-night time-asleep total, not a single stage", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date("2026-06-04T07:00:00.000Z"), - } as never); // One overnight session of granular stages, each row ONE stage (minutes). // Total time asleep = CORE + DEEP + REM = 300 + 90 + 75 = 465 min (7.75 h). @@ -161,14 +180,10 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)" // granular stage + IN_BED + AWAKE into one day bucket (~1490 min ≈ 24.8 h). // Post-fix the graded series is built from the deduped per-night points, so // every recent bucket mean is the night total (480 min = 8 h). - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); // A recent night (last 24 h) so it lands in `graded.recent`. const wake = new Date(Date.now() - 6 * 60 * 60 * 1000); @@ -329,7 +344,6 @@ describe("archetype prompt templates", () => { describe("generateMetricStatus — empty-data guard", () => { it("returns insufficient WITHOUT calling the provider when the metric has no data", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never); const result = await generateMetricStatus({ @@ -358,16 +372,12 @@ describe("generateMetricStatus — generation path", () => { }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.count).mockResolvedValue(400 as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); const captured = { systemPrompt: null, userPrompt: null } as { systemPrompt: string | null; @@ -396,12 +406,9 @@ describe("generateMetricStatus — generation path", () => { expect(snapshot.metric.unit).toBe("bpm"); // Persisted under the generic scope cache action. - const createCall = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { action: string; details: string }; - }; - expect(createCall.data.action).toBe( - "insights.metric:RESTING_HEART_RATE-status.en", - ); + const [note] = writtenNotes(prisma.insightStatusCache.upsert); + expect(note.metric).toBe("metric:RESTING_HEART_RATE"); + expect(note.locale).toBe("en"); // v1.12.1 — the diversity context (variety lead + explicit data strength) // reaches the user prompt, and the relations fetch is keyed by this @@ -424,16 +431,12 @@ describe("generateMetricStatus — generation path", () => { measuredAt: new Date(now.getTime() - day * dayMs), }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.count).mockResolvedValue(60 as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValueOnce([ { interpretation: @@ -464,7 +467,6 @@ describe("generateMetricStatus — generation path", () => { }); it("strips chart tokens from the persisted text", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.count).mockResolvedValue(3 as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 58, measuredAt: new Date() }, @@ -473,9 +475,6 @@ describe("generateMetricStatus — generation path", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Steady. metric:RESTING_HEART_RATE Good baseline."}', diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index 91dc4e8b0..f0dce0b46 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, moodEntry: { findMany: vi.fn() }, measurement: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, @@ -11,8 +15,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -23,6 +40,7 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateMoodStatusForUser } from "../mood-status"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -46,6 +64,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); // The snapshot threads the user's source priority into the factor crosstab; // default to "no custom priority" so every test resolves it without stubbing. vi.mocked(prisma.user.findUnique).mockResolvedValue({ @@ -72,12 +95,8 @@ describe("generateMoodStatusForUser — graded payload", () => { }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue(entries as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -108,7 +127,6 @@ describe("generateMoodStatusForUser — graded payload", () => { describe("generateMoodStatusForUser — timeout/error never persists", () => { it("serves the fallback without writing a cache row on timeout", async () => { const t = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ { date: t.toISOString().slice(0, 10), @@ -118,9 +136,6 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => { }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -132,37 +147,33 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => { expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateMoodStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateMoodStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", }).format(now); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Mood fallback text…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ { date: now.toISOString().slice(0, 10), @@ -172,9 +183,6 @@ describe("generateMoodStatusForUser — cache-read skips a stub", () => { }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh mood assessment."}'); @@ -189,7 +197,6 @@ describe("generateMoodStatusForUser — cache-read skips a stub", () => { describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { const t = new Date(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ { date: t.toISOString().slice(0, 10), @@ -199,9 +206,6 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion('{"summary":"Mood stayed positive. metric:MOOD"}'); @@ -209,11 +213,8 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); diff --git a/src/lib/insights/__tests__/payload-budget.test.ts b/src/lib/insights/__tests__/payload-budget.test.ts index 11de84479..7e113e547 100644 --- a/src/lib/insights/__tests__/payload-budget.test.ts +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -15,7 +15,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.18.11 (P6) — the slow-mover input gate probes salient inputs. measurement: { findMany: vi.fn(), groupBy: vi.fn() }, measurementRollup: { findMany: vi.fn() }, @@ -27,8 +31,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -68,11 +85,12 @@ function snapshotChars(): number { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); capturedPrompt = null; - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 508cff3b2..9528fbd4a 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -12,17 +12,21 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; * (it is a deterministic, signal-grounded line, not a fresh AI assessment), * and the served text names the user's own value rather than a generic tip. * - * v1.8.3 — the timeout path now writes a *short-TTL negative stub* - * (`{ timeout:true, model:"timeout-stub", retryAt }`). It is explicitly - * rejected by `readFreshStatusText`, so it can never hide the real - * assessment; its sole purpose is to stop the read-only route re-enqueuing - * generation on every navigation while a provider is degraded. + * v1.8.3 — the timeout path opens a *short-TTL negative window* on the note + * row (`retryAt`, `negativeReason`). Writing it never touches the stored + * note, so it can never hide the real assessment; its sole purpose is to + * stop the read-only route re-enqueuing generation on every navigation while + * a provider is degraded. */ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, measurementRollup: { findMany: vi.fn() }, // v1.28.25 — the graded-series cold-tier fallback day-buckets dense @@ -34,8 +38,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -49,6 +66,11 @@ import { generatePulseStatusForUser } from "../pulse-status"; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); }); @@ -59,7 +81,6 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 72, measuredAt: new Date() }, ] as never); @@ -86,19 +107,34 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { // never mislabels the fallback as a fresh assessment. expect(result.updatedAt).toBeNull(); - // v1.8.3 — a short-TTL negative stub IS persisted (fire-and-forget) so + // v1.8.3 — a short-TTL negative window IS opened (fire-and-forget) so // the read-only route doesn't re-enqueue on every navigation while the - // provider is degraded. It is marked as a timeout stub, which - // `readFreshStatusText` rejects, so it never hides the real assessment. + // provider is degraded. It carries no note text, so it never hides or + // replaces the real assessment. // The write is fire-and-forget (`void`), so flush the microtask queue. await Promise.resolve(); - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const persisted = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; + expect(prisma.insightStatusCache.upsert).toHaveBeenCalledTimes(1); + const persisted = vi.mocked(prisma.insightStatusCache.upsert).mock + .calls[0][0] as { + where: { userId_metric_locale: Record }; + create: Record; + update: Record; }; - const stub = JSON.parse(persisted.data.details); - expect(stub.timeout).toBe(true); - expect(stub.model).toBe("timeout-stub"); - expect(typeof stub.retryAt).toBe("string"); + expect(persisted.where.userId_metric_locale).toEqual({ + userId: "user-1", + metric: "pulse", + locale: "en", + }); + expect(persisted.create.textEncrypted).toBeUndefined(); + expect(persisted.create.negativeReason).toBe("timeout"); + // The update arm moves only the window, never an existing note. + expect(Object.keys(persisted.update).sort()).toEqual([ + "negativeReason", + "retryAt", + ]); + expect(persisted.update.retryAt).toBeInstanceOf(Date); + expect((persisted.update.retryAt as Date).getTime()).toBeGreaterThan( + Date.now(), + ); }); }); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 1aed06d5c..8dd617ee2 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, measurement: { findMany: vi.fn() }, measurementRollup: { findMany: vi.fn() }, // v1.28.25 — the graded-series cold-tier fallback day-buckets dense @@ -15,8 +19,21 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available in these fixtures — the capability read has its +// own tests in status-cache.test.ts. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), })); vi.mock("@/lib/insights/memory", () => ({ @@ -27,6 +44,7 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generatePulseStatusForUser } from "../pulse-status"; +import { noteRow, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -50,6 +68,11 @@ function stubCompletion( beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); // Cold rollup tier: the pulse graded series folds monthly/yearly from // the full-history `measurement.findMany` fallback on a tier miss. @@ -71,7 +94,6 @@ describe("generatePulseStatusForUser — graded payload", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); // v1.28.25 — PULSE is a dense type, so the cold-tier fallback reads // a SQL day-bucket aggregate instead of the raw findMany walk. Feed @@ -82,9 +104,6 @@ describe("generatePulseStatusForUser — graded payload", () => { .reverse() as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -146,16 +165,12 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); // pulse-status reads PULSE first, then RESTING_HEART_RATE. vi.mocked(prisma.measurement.findMany) .mockResolvedValueOnce(pulseRecords as never) .mockResolvedValueOnce(restingRecords as never) .mockResolvedValue([] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -194,15 +209,11 @@ describe("generatePulseStatusForUser — A2 resting-target in-target %", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany) .mockResolvedValueOnce(pulseRecords as never) .mockResolvedValueOnce([] as never); // no RESTING_HEART_RATE vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -234,15 +245,13 @@ describe("generatePulseStatusForUser — per-user tz (QA F5)", () => { gender: null, timezone: "America/New_York", } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: nyToday, - locale: "en", text: "NY-anchored cached pulse text.", - model: "x", - }), - } as never); + generatedAt: new Date(), + }) as never, + ); const result = await generatePulseStatusForUser("user-ny", { locale: "en", @@ -259,8 +268,8 @@ describe("generatePulseStatusForUser — per-user tz (QA F5)", () => { }); }); -describe("generatePulseStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generatePulseStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", @@ -269,23 +278,19 @@ describe("generatePulseStatusForUser — cache-read skips a stub", () => { dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Pulse fallback text…", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 72, measuredAt: now }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh pulse assessment."}'); @@ -303,14 +308,10 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () dateOfBirth: null, gender: null, } as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { value: 72, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Your pulse is stable. metric:PULSE The 7-day average sits inside the band."}', @@ -320,12 +321,9 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", () expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); - expect(parsed.text).toContain("Your pulse is stable."); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); + expect(notes[0].text).toContain("Your pulse is stable."); }); }); diff --git a/src/lib/insights/__tests__/status-batch.test.ts b/src/lib/insights/__tests__/status-batch.test.ts index dfcc82827..6aaf2dff5 100644 --- a/src/lib/insights/__tests__/status-batch.test.ts +++ b/src/lib/insights/__tests__/status-batch.test.ts @@ -29,6 +29,10 @@ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), +})); + // The single-card fallback path — assert it is invoked for omitted / failed // metrics, but don't re-drive a real completion. vi.mock("@/lib/insights/status-card-generation", async (importOriginal) => { @@ -48,6 +52,7 @@ import { prepareMedicationComplianceStatusForUser } from "@/lib/insights/medicat import { prepareGeneralStatusForUser } from "@/lib/insights/general-status"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { runPreparedStatusCard } from "@/lib/insights/status-card-generation"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { generateStatusBatchForUser } from "../status-batch"; function servedCard() { @@ -101,6 +106,37 @@ const ALL_PREPARES = [ beforeEach(() => { vi.resetAllMocks(); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + } as never); +}); + +describe("generateStatusBatchForUser — the statusText capability comes first", () => { + it("builds no snapshot and makes no call while statusText is unavailable", async () => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: false, + } as never); + ALL_PREPARES.forEach((prepare, i) => { + vi.mocked(prepare).mockResolvedValue(pendingCard(`m${i}`) as never); + }); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText"); + for (const prepare of ALL_PREPARES) expect(prepare).not.toHaveBeenCalled(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(runPreparedStatusCard).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + served: 0, + batched: 0, + fellBack: 0, + batchCallMade: false, + }); + }); }); describe("generateStatusBatchForUser — one call covers all present metrics", () => { @@ -166,6 +202,10 @@ describe("generateStatusBatchForUser — one call covers all present metrics", ( // ONE provider round-trip for all seven metrics. expect(runStatusCompletion).toHaveBeenCalledTimes(1); + // The chokepoint re-checks the same capability at the wire. + expect(runStatusCompletion).toHaveBeenCalledWith( + expect.objectContaining({ capability: "statusText" }), + ); expect(result.batched).toBe(7); expect(result.fellBack).toBe(0); // Each metric's finalize got its own summary, wrapped in the { summary } @@ -360,7 +400,10 @@ describe("generateStatusBatchForUser — graceful partial degradation", () => { pendingCard(`m${i}`, finalizers[i]) as never, ); }); - vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never); + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "none", + reason: "no_provider", + } as never); const result = await generateStatusBatchForUser("u1", { locale: "en" }); diff --git a/src/lib/insights/__tests__/status-cache-delegated.test.ts b/src/lib/insights/__tests__/status-cache-delegated.test.ts index 45afb40fe..049fca9b4 100644 --- a/src/lib/insights/__tests__/status-cache-delegated.test.ts +++ b/src/lib/insights/__tests__/status-cache-delegated.test.ts @@ -16,8 +16,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const enqueueStatusGeneration = vi.fn(async () => {}); -const hasUsableStatusProvider = vi.fn(async () => true); -const statusConsentBlocksGeneration = vi.fn(async () => false); +const probeProviderPresence = vi.fn(async () => true); +const aiCapabilityForRecord = vi.fn(async () => ({ + available: true, + reason: null, + onDeviceAllowed: true, +})); const suppressed = vi.fn(() => false); vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ @@ -26,11 +30,14 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ STATUS_GENERATE_QUEUE: "insight-status-generate", })); -vi.mock("@/lib/insights/status-provider", () => ({ - hasUsableStatusProvider: (...args: unknown[]) => - hasUsableStatusProvider(...(args as [])), - statusConsentBlocksGeneration: (...args: unknown[]) => - statusConsentBlocksGeneration(...(args as [])), +vi.mock("@/lib/ai/provider", () => ({ + probeProviderPresence: (...args: unknown[]) => + probeProviderPresence(...(args as [])), +})); + +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: (...args: unknown[]) => + aiCapabilityForRecord(...(args as [])), })); vi.mock("@/lib/sharing/delegated-generation", () => ({ @@ -41,11 +48,10 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: { - // The last-good text and the negative-cache probe both read the audit - // ledger; an empty ledger is the cold-cache case this function is for. - auditLog: { - findMany: vi.fn(async () => []), - findFirst: vi.fn(async () => null), + // The last-good text and the negative-cache probe both read the one + // status-note row; no row is the cold-cache case this function is for. + insightStatusCache: { + findUnique: vi.fn(async () => null), }, }, })); @@ -57,11 +63,9 @@ beforeEach(() => { // the provider probe's call count, and a counter carried over from the // previous test would make it fail for the wrong reason (or pass for one). enqueueStatusGeneration.mockClear(); - hasUsableStatusProvider.mockClear(); - statusConsentBlocksGeneration.mockClear(); + probeProviderPresence.mockClear(); + aiCapabilityForRecord.mockClear(); suppressed.mockReturnValue(false); - hasUsableStatusProvider.mockResolvedValue(true); - statusConsentBlocksGeneration.mockResolvedValue(false); }); const ARGS = { @@ -103,7 +107,9 @@ describe("resolveReadOnlyStatusMiss — the delegated path", () => { await resolveReadOnlyStatusMiss(ARGS); - expect(hasUsableStatusProvider).not.toHaveBeenCalled(); - expect(statusConsentBlocksGeneration).not.toHaveBeenCalled(); + // The capability is the one read allowed ahead of the decision; the + // provider-presence probe belongs to the unavailable branch only. + expect(aiCapabilityForRecord).toHaveBeenCalledWith("owner-1", "statusText"); + expect(probeProviderPresence).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index 0a4334b86..210abb34e 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + findMany: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.18.11 (P6) — the input gate / fingerprint probe salient inputs. measurement: { groupBy: vi.fn() }, moodEntry: { aggregate: vi.fn() }, @@ -10,12 +15,26 @@ vi.mock("@/lib/db", () => ({ }, })); -const hasUsableStatusProvider = vi.fn(); -const statusConsentBlocksGeneration = vi.fn(); -vi.mock("@/lib/insights/status-provider", () => ({ - hasUsableStatusProvider: (...a: unknown[]) => hasUsableStatusProvider(...a), - statusConsentBlocksGeneration: (...a: unknown[]) => - statusConsentBlocksGeneration(...a), +// A reversible stand-in for the at-rest codec: the store's own logic (what it +// serves, what it re-dates, what it leaves alone) is what these tests pin, not +// AES-GCM. +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (plain: string) => new TextEncoder().encode(`enc:${plain}`), + decryptFromBytes: (buf: Uint8Array) => { + const text = new TextDecoder().decode(buf); + if (!text.startsWith("enc:")) throw new Error("unknown key id"); + return text.slice(4); + }, +})); + +const aiCapabilityForRecord = vi.fn(); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForRecord: (...a: unknown[]) => aiCapabilityForRecord(...a), +})); + +const probeProviderPresence = vi.fn(); +vi.mock("@/lib/ai/provider", () => ({ + probeProviderPresence: (...a: unknown[]) => probeProviderPresence(...a), })); const enqueueStatusGeneration = vi.fn(); @@ -27,46 +46,108 @@ import { prisma } from "@/lib/db"; import { computeStatusInputFingerprint, gateUnchangedStatusInput, - isTimeoutStub, readFreshStatusText, + readLastGoodStatusText, + readStatusNegativeCache, refreshUnchangedStatusInsight, resolveReadOnlyStatusMiss, + writeStatusNegativeWindow, + writeStatusNote, } from "../status-cache"; const TODAY = "2026-05-31"; +const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true }; -function cacheRow(details: Record, createdAt = new Date()) { - return { createdAt, details: JSON.stringify(details) }; +function enc(text: string): Uint8Array { + return new TextEncoder().encode(`enc:${text}`); +} + +/** One `InsightStatusCache` row as `findUnique` would return it. */ +function noteRow(fields: { + text?: string | null; + dateKey?: string; + generatedAt?: Date | null; + snapshotHash?: string | null; + inputHash?: string | null; + retryAt?: Date | null; + negativeReason?: string | null; + textEncrypted?: Uint8Array | null; +}) { + return { + textEncrypted: + fields.textEncrypted !== undefined + ? fields.textEncrypted + : fields.text == null + ? null + : enc(fields.text), + itemsEncrypted: null, + inputHash: fields.inputHash ?? null, + snapshotHash: fields.snapshotHash ?? null, + dateKey: fields.dateKey ?? TODAY, + generatedAt: + fields.generatedAt !== undefined ? fields.generatedAt : new Date(), + retryAt: fields.retryAt ?? null, + negativeReason: fields.negativeReason ?? null, + }; } beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); + aiCapabilityForRecord.mockResolvedValue(AVAILABLE); + probeProviderPresence.mockResolvedValue(true); }); -describe("isTimeoutStub", () => { - it("flags the timeout-stub model marker", () => { - expect(isTimeoutStub({ model: "timeout-stub" })).toBe(true); +describe("writeStatusNote / writeStatusNegativeWindow", () => { + it("upserts one encrypted row per (user, metric, locale) and clears the negative window", async () => { + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + await writeStatusNote({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + text: "Stable.", + snapshotHash: "s".repeat(64), + }); + const arg = vi.mocked(prisma.insightStatusCache.upsert).mock + .calls[0][0] as { + where: { userId_metric_locale: Record }; + create: Record; + update: Record; + }; + expect(arg.where.userId_metric_locale).toEqual({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect( + new TextDecoder().decode(arg.update.textEncrypted as Uint8Array), + ).toBe("enc:Stable."); + expect(arg.update.dateKey).toBe(TODAY); + expect(arg.update.snapshotHash).toBe("s".repeat(64)); + expect(arg.update.retryAt).toBeNull(); + expect(arg.update.negativeReason).toBeNull(); }); - it("flags the timeout:true marker", () => { - expect(isTimeoutStub({ timeout: true })).toBe(true); - }); - - it("does not flag a real assessment", () => { - expect(isTimeoutStub({ model: "gpt-4o-mini", timeout: false })).toBe(false); - expect(isTimeoutStub({})).toBe(false); + it("opens a negative window without touching the stored note", async () => { + const retryAt = new Date(Date.now() + 60_000); + await writeStatusNegativeWindow({ + userId: "u1", + cacheAction: "insights.pulse-status.de", + todayKey: TODAY, + reason: "timeout", + retryAt, + }); + const arg = vi.mocked(prisma.insightStatusCache.upsert).mock + .calls[0][0] as { update: Record }; + // Only the window moves: a stall must not hide yesterday's text. + expect(arg.update).toEqual({ retryAt, negativeReason: "timeout" }); }); }); describe("readFreshStatusText", () => { it("returns today's real assessment text", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: TODAY, - text: "Your weight trend is stable.", - model: "gpt-4o-mini", - }) as never, + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: "Your weight trend is stable." }) as never, ); const hit = await readFreshStatusText({ userId: "u1", @@ -75,15 +156,44 @@ describe("readFreshStatusText", () => { force: false, }); expect(hit?.text).toBe("Your weight trend is stable."); + expect(prisma.insightStatusCache.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId_metric_locale: { + userId: "u1", + metric: "weight", + locale: "en", + }, + }, + }), + ); }); - it("skips a timeout-stub row keyed to today", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: TODAY, - text: "Generic fallback advice.", - model: "timeout-stub", - timeout: true, + it("serves today's note even while a negative window is open on it", async () => { + // A stall writes the window next to the note; it never shadows the text. + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + text: "Real assessment.", + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", + }) as never, + ); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit?.text).toBe("Real assessment."); + }); + + it("misses on a row that carries only a negative window", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + text: null, + generatedAt: null, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", }) as never, ); const hit = await readFreshStatusText({ @@ -96,8 +206,8 @@ describe("readFreshStatusText", () => { }); it("skips a stale-day row", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never, + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never, ); const hit = await readFreshStatusText({ userId: "u1", @@ -109,8 +219,8 @@ describe("readFreshStatusText", () => { }); it("skips an empty-text row", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ dateKey: TODAY, text: " " }) as never, + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: " " }) as never, ); const hit = await readFreshStatusText({ userId: "u1", @@ -129,14 +239,15 @@ describe("readFreshStatusText", () => { force: true, }); expect(hit).toBeNull(); - expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); }); - it("treats a malformed payload as a miss", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: new Date(), - details: "{not json", - } as never); + it("treats a note it cannot decrypt as a miss", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + textEncrypted: new TextEncoder().encode("{not ciphertext"), + }) as never, + ); const hit = await readFreshStatusText({ userId: "u1", cacheAction: "insights.weight-status.en", @@ -145,35 +256,153 @@ describe("readFreshStatusText", () => { }); expect(hit).toBeNull(); }); + + it("returns null when statusText is unavailable, without reading the row", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: "Would have been served." }) as never, + ); + const hit = await readFreshStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + force: false, + }); + expect(hit).toBeNull(); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("readLastGoodStatusText", () => { + it("serves a note from an earlier day", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + dateKey: "2026-05-29", + text: "Two days old.", + generatedAt: new Date("2026-05-29T04:30:00.000Z"), + }) as never, + ); + expect( + await readLastGoodStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + }), + ).toEqual({ + text: "Two days old.", + updatedAt: "2026-05-29T04:30:00.000Z", + }); + }); + + it("serves nothing when statusText is unavailable", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: false, + }); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: "Old text." }) as never, + ); + expect( + await readLastGoodStatusText({ + userId: "u1", + cacheAction: "insights.weight-status.en", + }), + ).toBeNull(); + }); +}); + +describe("readStatusNegativeCache", () => { + it("reports an open window and flips retryable once retryAt passes", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow({ + text: "Kept.", + retryAt: new Date(Date.now() + 60_000), + negativeReason: "screened", + }) as never, + ); + const open = await readStatusNegativeCache({ + userId: "u1", + cacheAction: "insights.weight-status.en", + }); + expect(open).toMatchObject({ reason: "screened", retryable: false }); + + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow({ + text: "Kept.", + retryAt: new Date(Date.now() - 60_000), + negativeReason: "timeout", + }) as never, + ); + const closed = await readStatusNegativeCache({ + userId: "u1", + cacheAction: "insights.weight-status.en", + }); + expect(closed).toMatchObject({ reason: "timeout", retryable: true }); + }); }); describe("resolveReadOnlyStatusMiss", () => { beforeEach(() => { - // Default: no prior assessment to serve stale (readLastGoodStatusText). - vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); + // Default: no row at all — no last-good text, no negative window. + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + null as never, + ); }); - it("returns no-provider without enqueuing when the user has no provider", async () => { - hasUsableStatusProvider.mockResolvedValue(false); + it("returns unavailable with hasProvider from the probe and enqueues nothing", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: false, + }); + probeProviderPresence.mockResolvedValue(true); const outcome = await resolveReadOnlyStatusMiss({ userId: "u1", metric: "weight", locale: "en", }); - expect(outcome.kind).toBe("no-provider"); + // A withdrawn consent is not "no provider": the probe says one exists. + expect(outcome).toEqual({ + kind: "unavailable", + reason: "consent_required", + hasProvider: true, + }); + expect(probeProviderPresence).toHaveBeenCalledWith("u1", "text"); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + }); + + it("returns unavailable/no_provider without enqueuing when the user has no provider", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "no_provider", + onDeviceAllowed: false, + }); + probeProviderPresence.mockResolvedValue(false); + const outcome = await resolveReadOnlyStatusMiss({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect(outcome).toEqual({ + kind: "unavailable", + reason: "no_provider", + hasProvider: false, + }); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); it("enqueues generation and returns preparing on a clean miss", async () => { - hasUsableStatusProvider.mockResolvedValue(true); - // No negative stub present. - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); const outcome = await resolveReadOnlyStatusMiss({ userId: "u1", metric: "pulse", locale: "de", }); - expect(outcome.kind).toBe("preparing"); // No last-good text to show, so nothing to revalidate against — the card // polls on `preparing` alone. expect(outcome).toEqual({ @@ -189,19 +418,14 @@ describe("resolveReadOnlyStatusMiss", () => { }); it("serves the last good assessment stale-while-revalidate on a clean miss", async () => { - hasUsableStatusProvider.mockResolvedValue(true); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); // A prior (e.g. yesterday's) real assessment is on record. - vi.mocked(prisma.auditLog.findMany).mockResolvedValue([ - cacheRow( - { - dateKey: "2026-05-30", - text: "Steady upward trend.", - model: "gpt-4o-mini", - }, - new Date("2026-05-30T04:30:00.000Z"), - ), - ] as never); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + dateKey: "2026-05-30", + text: "Steady upward trend.", + generatedAt: new Date("2026-05-30T04:30:00.000Z"), + }) as never, + ); const outcome = await resolveReadOnlyStatusMiss({ userId: "u1", metric: "weight", @@ -217,14 +441,13 @@ describe("resolveReadOnlyStatusMiss", () => { expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); }); - it("suppresses re-enqueue while a fresh timeout stub exists", async () => { - hasUsableStatusProvider.mockResolvedValue(true); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: TODAY, - timeout: true, - model: "timeout-stub", - retryAt: new Date(Date.now() + 60_000).toISOString(), + it("suppresses re-enqueue while a negative window is open, still serving the last note", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + dateKey: "2026-05-30", + text: "Yesterday's note.", + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", }) as never, ); const outcome = await resolveReadOnlyStatusMiss({ @@ -234,19 +457,20 @@ describe("resolveReadOnlyStatusMiss", () => { }); expect(outcome.kind).toBe("preparing"); if (outcome.kind !== "preparing") throw new Error("expected preparing"); + // The window does not hide the stored note. + expect(outcome.lastGood?.text).toBe("Yesterday's note."); // No enqueue on the suppressed branch → nothing in flight to revalidate. expect(outcome.revalidating).toBe(false); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); - it("re-enqueues once the negative stub's retryAt has passed", async () => { - hasUsableStatusProvider.mockResolvedValue(true); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: TODAY, - timeout: true, - model: "timeout-stub", - retryAt: new Date(Date.now() - 60_000).toISOString(), + it("re-enqueues once the negative window's retryAt has passed", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + text: null, + generatedAt: null, + retryAt: new Date(Date.now() - 60_000), + negativeReason: "timeout", }) as never, ); const outcome = await resolveReadOnlyStatusMiss({ @@ -262,13 +486,12 @@ describe("resolveReadOnlyStatusMiss", () => { describe("refreshUnchangedStatusInsight (v1.16.8)", () => { const HASH = "a".repeat(64); - beforeEach(() => { - // Default: consent does not block (BYOK / consented chains). - statusConsentBlocksGeneration.mockResolvedValue(false); - }); - - it("misses (and writes nothing) when the server-managed consent is revoked, even on a hash match", async () => { - statusConsentBlocksGeneration.mockResolvedValue(true); + it("misses (and writes nothing) when statusText is unavailable, even on a hash match", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "consent_required", + onDeviceAllowed: false, + }); const hit = await refreshUnchangedStatusInsight({ userId: "u1", cacheAction: "insights.weight-status.en", @@ -276,31 +499,24 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { snapshotHash: HASH, }); expect(hit).toBeNull(); - // The gate must not even read the cache row — a revoked consent can - // never re-stamp old AI text as today's assessment. - expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); - expect(statusConsentBlocksGeneration).toHaveBeenCalledWith( - "u1", - "insights", - ); + // The gate must not even read the row — an unavailable capability can + // never re-date old AI text as today's assessment. + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "statusText"); }); - it("re-persists the row under today's dateKey and returns the text on a hash match", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ + it("re-dates the row under today's dateKey and returns the text on a hash match", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", - locale: "en", text: "Stable weight, no concerns.", - providerType: "openai", - model: "gpt-4o-mini", - tokensUsed: 321, snapshotHash: HASH, }) as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date("2026-05-31T04:30:00.000Z"), - } as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); const hit = await refreshUnchangedStatusInsight({ userId: "u1", @@ -310,27 +526,33 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { }); expect(hit?.text).toBe("Stable weight, no concerns."); - expect(hit?.updatedAt).toBe("2026-05-31T04:30:00.000Z"); - // The refresh row carries the same payload re-keyed to today, so the - // read path and the ingest debounce both see a current assessment. - expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); - const arg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { userId: string; action: string; details: string }; + expect(hit?.expiresAfterDateKey).toBe(TODAY); + // The re-date moves only the day key and timestamp: the note and its + // fingerprints stay, so the read path and the ingest debounce both see a + // current assessment and tomorrow's gate still matches. + expect(prisma.insightStatusCache.updateMany).toHaveBeenCalledTimes(1); + const arg = vi.mocked(prisma.insightStatusCache.updateMany).mock + .calls[0][0] as { + where: Record; + data: Record; }; - expect(arg.data.userId).toBe("u1"); - expect(arg.data.action).toBe("insights.weight-status.en"); - const details = JSON.parse(arg.data.details) as Record; - expect(details.dateKey).toBe(TODAY); - expect(details.text).toBe("Stable weight, no concerns."); - expect(details.snapshotHash).toBe(HASH); + expect(arg.where).toEqual({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect(arg.data.dateKey).toBe(TODAY); + expect(arg.data).not.toHaveProperty("textEncrypted"); + expect(arg.data).not.toHaveProperty("snapshotHash"); + expect(hit?.updatedAt).toBe((arg.data.generatedAt as Date).toISOString()); + expect(prisma.insightStatusCache.upsert).not.toHaveBeenCalled(); }); it("misses (and writes nothing) when the stored hash differs", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Older text.", - model: "gpt-4o-mini", snapshotHash: "b".repeat(64), }) as never, ); @@ -341,15 +563,31 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { snapshotHash: HASH, }); expect(hit).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); - it("misses when the latest row carries no hash (pre-gate rows)", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: "2026-05-30", - text: "Older text.", - model: "gpt-4o-mini", + it("misses when the row carries no hash", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Older text." }) as never, + ); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); + }); + + it("never refreshes off a row that carries only a negative window", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + text: null, + generatedAt: null, + snapshotHash: HASH, + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", }) as never, ); const hit = await refreshUnchangedStatusInsight({ @@ -359,31 +597,28 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { snapshotHash: HASH, }); expect(hit).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); - it("never refreshes off a timeout stub, even with a matching hash", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ - dateKey: "2026-05-30", - text: "Generic fallback advice.", - model: "timeout-stub", - timeout: true, + it("misses on no prior row and on an undecryptable note", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + null as never, + ); + expect( + await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }), + ).toBeNull(); + + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValueOnce( + noteRow({ + textEncrypted: new TextEncoder().encode("{not ciphertext"), snapshotHash: HASH, }) as never, ); - const hit = await refreshUnchangedStatusInsight({ - userId: "u1", - cacheAction: "insights.weight-status.en", - todayKey: TODAY, - snapshotHash: HASH, - }); - expect(hit).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); - }); - - it("misses on no prior row and on malformed payloads", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null as never); expect( await refreshUnchangedStatusInsight({ userId: "u1", @@ -392,27 +627,11 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => { snapshotHash: HASH, }), ).toBeNull(); - - vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({ - details: "{not json", - } as never); - expect( - await refreshUnchangedStatusInsight({ - userId: "u1", - cacheAction: "insights.weight-status.en", - todayKey: TODAY, - snapshotHash: HASH, - }), - ).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); describe("computeStatusInputFingerprint (v1.18.11 P6)", () => { - beforeEach(() => { - statusConsentBlocksGeneration.mockResolvedValue(false); - }); - it("is stable across group order and flips when a count or newest moves", async () => { const t0 = new Date("2026-05-30T08:00:00.000Z"); vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ @@ -598,24 +817,18 @@ describe("computeStatusInputFingerprint (v1.18.11 P6)", () => { describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { const INPUT = "b".repeat(64); - beforeEach(() => { - statusConsentBlocksGeneration.mockResolvedValue(false); - }); - - it("re-stamps the cached text and skips the build on a matching input hash", async () => { - const created = new Date("2026-05-31T02:00:00.000Z"); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ + it("re-dates the cached text and skips the build on a matching input hash", async () => { + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Stable weight.", - model: "gpt-4o-mini", inputHash: INPUT, snapshotHash: "c".repeat(64), }) as never, ); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: created, - } as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); const hit = await gateUnchangedStatusInput({ userId: "u1", @@ -625,25 +838,23 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { force: false, }); expect(hit?.text).toBe("Stable weight."); - const persisted = JSON.parse( - ( - vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; - } - ).data.details, - ) as { dateKey: string; inputHash: string; snapshotHash: string }; - expect(persisted.dateKey).toBe(TODAY); - // The prior fingerprints are preserved so the next day's gates match. - expect(persisted.inputHash).toBe(INPUT); - expect(persisted.snapshotHash).toBe("c".repeat(64)); + const data = ( + vi.mocked(prisma.insightStatusCache.updateMany).mock.calls[0][0] as { + data: Record; + } + ).data; + expect(data.dateKey).toBe(TODAY); + // The prior fingerprints are preserved (not rewritten) so the next day's + // gates match. + expect(data).not.toHaveProperty("inputHash"); + expect(data).not.toHaveProperty("snapshotHash"); }); it("misses on a differing or missing input hash (caller builds)", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( - cacheRow({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-05-30", text: "Stable weight.", - model: "gpt-4o-mini", // no inputHash on the row }) as never, ); @@ -656,7 +867,7 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { force: false, }), ).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); it("misses on a forced run without touching the cache", async () => { @@ -669,11 +880,18 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { force: true, }), ).toBeNull(); - expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); }); - it("misses when the server-managed consent gate would block (no stale re-date)", async () => { - statusConsentBlocksGeneration.mockResolvedValue(true); + it("misses when statusText is unavailable (no stale re-date)", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + }); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ text: "Stable weight.", inputHash: INPUT }) as never, + ); expect( await gateUnchangedStatusInput({ userId: "u1", @@ -683,6 +901,6 @@ describe("gateUnchangedStatusInput (v1.18.11 P6)", () => { force: false, }), ).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/insights/__tests__/status-note-fixtures.ts b/src/lib/insights/__tests__/status-note-fixtures.ts new file mode 100644 index 000000000..c8d93f504 --- /dev/null +++ b/src/lib/insights/__tests__/status-note-fixtures.ts @@ -0,0 +1,105 @@ +/** + * Shared fixtures for the status-generator tests that run against the real + * status-note store (`status-cache.ts`) over a mocked `prisma`. + * + * `fakeBytesCodec` stands in for the at-rest codec so a fixture can build a + * stored note from plain text and read back what a generator wrote. Wire it + * with + * + * vi.mock("@/lib/ai/coach/bytes-codec", async () => + * (await import("./status-note-fixtures")).fakeBytesCodec, + * ); + * + * It is not AES-GCM and does not try to be: these tests pin what the store + * serves, re-dates and writes, not the cipher. + */ +import type { Mock } from "vitest"; + +const PREFIX = "enc:"; + +export const fakeBytesCodec = { + encryptToBytes: (plain: string): Uint8Array => + new TextEncoder().encode(`${PREFIX}${plain}`), + decryptFromBytes: (buf: Uint8Array): string => { + const text = new TextDecoder().decode(buf); + if (!text.startsWith(PREFIX)) throw new Error("unknown key id"); + return text.slice(PREFIX.length); + }, +}; + +/** The capability state every generator fixture runs under by default. */ +export const CAPABILITY_AVAILABLE = { + available: true, + reason: null, + onDeviceAllowed: true, +} as const; + +/** One `InsightStatusCache` row as `findUnique` returns it. */ +export function noteRow(fields: { + text?: string | null; + items?: unknown; + dateKey: string; + generatedAt?: Date | null; + snapshotHash?: string | null; + inputHash?: string | null; + retryAt?: Date | null; + negativeReason?: string | null; +}) { + return { + textEncrypted: + fields.text == null ? null : fakeBytesCodec.encryptToBytes(fields.text), + itemsEncrypted: + fields.items === undefined + ? null + : fakeBytesCodec.encryptToBytes(JSON.stringify(fields.items)), + inputHash: fields.inputHash ?? null, + snapshotHash: fields.snapshotHash ?? null, + dateKey: fields.dateKey, + generatedAt: + fields.generatedAt !== undefined ? fields.generatedAt : new Date(), + retryAt: fields.retryAt ?? null, + negativeReason: fields.negativeReason ?? null, + }; +} + +/** A note a generator wrote through `insightStatusCache.upsert`, decoded. */ +export interface WrittenNote { + metric: string; + locale: string; + text: string | null; + items: unknown; + dateKey: string | undefined; + snapshotHash: string | null | undefined; + inputHash: string | null | undefined; + retryAt: Date | null | undefined; + negativeReason: string | null | undefined; +} + +/** + * Every `upsert` call on the mocked `insightStatusCache`, decoded from its + * `create` arm (which carries the full row either way). + */ +export function upsertedNotes(upsert: Mock | unknown): WrittenNote[] { + return (upsert as Mock).mock.calls.map((call) => { + const create = (call[0] as { create: Record }).create; + const decode = (v: unknown) => + v instanceof Uint8Array ? fakeBytesCodec.decryptFromBytes(v) : null; + const itemsJson = decode(create.itemsEncrypted); + return { + metric: create.metric as string, + locale: create.locale as string, + text: decode(create.textEncrypted), + items: itemsJson === null ? null : JSON.parse(itemsJson), + dateKey: create.dateKey as string | undefined, + snapshotHash: create.snapshotHash as string | null | undefined, + inputHash: create.inputHash as string | null | undefined, + retryAt: create.retryAt as Date | null | undefined, + negativeReason: create.negativeReason as string | null | undefined, + }; + }); +} + +/** Only the upserts that wrote a note (not a bare negative-cache window). */ +export function writtenNotes(upsert: Mock | unknown): WrittenNote[] { + return upsertedNotes(upsert).filter((n) => n.text !== null); +} diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index 1ef502f0e..05b44961b 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -64,7 +64,18 @@ const { resolveProviderChain, resolveProvider } = vi.hoisted(() => ({ resolveProviderChain: vi.fn(), resolveProvider: vi.fn(), })); -vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain, resolveProvider })); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain, + resolveProvider, + probeProviderPresence: vi.fn(async () => true), +})); + +// The capability re-check at the wire. Available by default so the budget is +// what these tests isolate; one test below flips it. +const { aiCapabilityForRecord } = vi.hoisted(() => ({ + aiCapabilityForRecord: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); const { runRawCompletionWithFallback } = vi.hoisted(() => ({ runRawCompletionWithFallback: vi.fn(), @@ -94,7 +105,7 @@ function completionArgs(overrides: Record = {}) { cacheAction: "status:test", systemPrompt: "system", userPrompt: "user", - consentSurface: "insights" as const, + capability: "statusText" as const, ...overrides, }; } @@ -113,6 +124,40 @@ beforeEach(() => { ledgerOperator = 0; resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN); resolveProvider.mockResolvedValue({ type: "none" }); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); +}); + +describe("runStatusCompletion — capability at the wire", () => { + it("returns none with the capability's reason and never resolves the chain when the capability is unavailable", async () => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason: "user_disabled", + onDeviceAllowed: false, + }); + + const outcome = await runStatusCompletion( + completionArgs({ capability: "periodNarrative" }) as never, + ); + + expect(outcome).toEqual({ kind: "none", reason: "user_disabled" }); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "periodNarrative"); + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(resolveProvider).not.toHaveBeenCalled(); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + // Nothing reserved against the day's ledger either. + expect(ledgerTotal).toBe(0); + }); + + it("returns none with reason no_provider when the chain is empty", async () => { + resolveProviderChain.mockResolvedValue([]); + const outcome = await runStatusCompletion(completionArgs() as never); + expect(outcome).toEqual({ kind: "none", reason: "no_provider" }); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); }); describe("runStatusCompletion — ledger accounting", () => { diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index ec4e98a37..d55d0fc8e 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn(), create: vi.fn() }, + insightStatusCache: { + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), + }, // v1.18.11 (P6) — the input gate probes salient inputs via groupBy + // moodEntry.aggregate before the heavy findMany build. measurement: { findMany: vi.fn(), groupBy: vi.fn() }, @@ -18,10 +22,39 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), - // Consent never blocks in these fixtures — the gate has its own tests. - statusConsentBlocksGeneration: vi.fn(async () => false), })); +vi.mock( + "@/lib/ai/coach/bytes-codec", + async () => (await import("./status-note-fixtures")).fakeBytesCodec, +); + +// statusText is available unless a test says otherwise — the capability +// read itself has its own tests in status-cache.test.ts. +const { + aiCapabilityForRecord, + probeProviderPresence, + enqueueStatusGeneration, +} = vi.hoisted(() => ({ + aiCapabilityForRecord: vi.fn(), + probeProviderPresence: vi.fn(), + enqueueStatusGeneration: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); +vi.mock("@/lib/ai/provider", async (importOriginal) => ({ + ...(await importOriginal()), + probeProviderPresence, +})); +vi.mock( + "@/lib/jobs/insight-status-generate-shared", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/jobs/insight-status-generate-shared") + >()), + enqueueStatusGeneration, + }), +); + vi.mock("@/lib/insights/memory", () => ({ getPreviousInsightContext: vi.fn().mockResolvedValue(null), formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), @@ -30,6 +63,8 @@ vi.mock("@/lib/insights/memory", () => ({ import { prisma } from "@/lib/db"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { generateWeightStatusForUser } from "../weight-status"; +import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks"; +import { noteRow, upsertedNotes, writtenNotes } from "./status-note-fixtures"; const dayMs = 24 * 60 * 60 * 1000; @@ -68,6 +103,49 @@ beforeEach(() => { _max: { moodLoggedAt: null }, } as never); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null); + vi.mocked(prisma.insightStatusCache.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.insightStatusCache.updateMany).mockResolvedValue( + {} as never, + ); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + probeProviderPresence.mockResolvedValue(true); + enqueueStatusGeneration.mockResolvedValue(undefined); +}); + +describe("generateWeightStatusForUser — read-only miss while statusText is unavailable", () => { + it.each([ + ["consent_required", true], + ["no_provider", false], + ] as const)( + "serves the deterministic line with hasProvider from the probe (%s) and enqueues nothing", + async (reason, present) => { + aiCapabilityForRecord.mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); + probeProviderPresence.mockResolvedValue(present); + + const result = await generateWeightStatusForUser("user-1", { + locale: "en", + readOnly: true, + }); + + // `hasProvider` is provider presence only: a withdrawn consent is not + // "no provider configured". + expect(result.hasProvider).toBe(present); + expect(result.text).toBe(getNoKeyWeightStatusText("en")); + expect(result.updatedAt).toBeNull(); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.findUnique).not.toHaveBeenCalled(); + }, + ); }); describe("generateWeightStatusForUser — graded payload", () => { @@ -86,12 +164,8 @@ describe("generateWeightStatusForUser — graded payload", () => { }); } - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); const captured: { userPrompt: string | null } = { userPrompt: null }; stubCompletion('{"summary":"OK"}', captured); @@ -127,14 +201,10 @@ describe("generateWeightStatusForUser — graded payload", () => { describe("generateWeightStatusForUser — timeout/error never persists", () => { it("serves the fallback without writing a cache row on timeout", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "timeout", @@ -148,44 +218,38 @@ describe("generateWeightStatusForUser — timeout/error never persists", () => { expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); // v1.8.3 — no real assessment persisted (updatedAt stays null above), - // but a short-TTL negative stub IS written so the read-only route does + // but a short-TTL negative window IS opened so the read-only route does // not re-enqueue on every navigation while the provider is degraded. - // The stub is a timeout marker that `readFreshStatusText` rejects. + // The window carries no note, so it can never be served as one. await Promise.resolve(); - for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { - const details = JSON.parse( - (call[0] as { data: { details: string } }).data.details, - ); - expect(details.timeout === true || details.model === "timeout-stub").toBe( - true, - ); - } + expect(writtenNotes(prisma.insightStatusCache.upsert)).toEqual([]); + const windows = upsertedNotes(prisma.insightStatusCache.upsert); + expect(windows).toHaveLength(1); + expect(windows[0].negativeReason).toBe("timeout"); + expect(windows[0].retryAt).toBeInstanceOf(Date); + expect(prisma.insightStatusCache.updateMany).not.toHaveBeenCalled(); }); }); -describe("generateWeightStatusForUser — cache-read skips a stub", () => { - it("regenerates when the only cached row is a timeout stub", async () => { +describe("generateWeightStatusForUser — a negative window is not a note", () => { + it("regenerates when today's row carries only a negative-cache window", async () => { const now = new Date(); const todayKey = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", }).format(now); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: todayKey, - locale: "en", - text: "Generic stub fallback.", - model: "timeout-stub", - timeout: true, - }), - } as never); + text: null, + generatedAt: null, + retryAt: new Date(now.getTime() + 60_000), + negativeReason: "timeout", + }) as never, + ); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: now }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"Fresh real assessment."}'); @@ -223,10 +287,6 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { vi.mocked(prisma.measurement.findMany).mockImplementation((async () => records.map((r) => ({ ...r }))) as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); stubCompletion('{"summary":"First real assessment."}'); // First run: a real generation persists the snapshot fingerprint. @@ -235,33 +295,24 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { force: true, }); expect(runStatusCompletion).toHaveBeenCalledTimes(1); - const persisted = JSON.parse( - ( - vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; - } - ).data.details, - ) as { text: string; snapshotHash: string }; + const [persisted] = writtenNotes(prisma.insightStatusCache.upsert); + expect(persisted.text).toBe("First real assessment."); expect(persisted.snapshotHash).toMatch(/^[0-9a-f]{64}$/); // Second run, same data: the gate finds the matching fingerprint, // re-persists the same text under today's dateKey, and never calls // the provider — even though the run is forced. vi.mocked(runStatusCompletion).mockClear(); - vi.mocked(prisma.auditLog.create).mockClear(); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ - // Yesterday's row — outside the same-day cache read. + vi.mocked(prisma.insightStatusCache.upsert).mockClear(); + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ + // Yesterday's note — outside the same-day cache read. dateKey: "2026-06-09", - locale: "en", text: persisted.text, - providerType: "anthropic", - model: "x", - tokensUsed: 1, + generatedAt: now, snapshotHash: persisted.snapshotHash, - }), - } as never); + }) as never, + ); const result = await generateWeightStatusForUser("user-1", { locale: "en", @@ -271,15 +322,17 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { expect(runStatusCompletion).not.toHaveBeenCalled(); expect(result.cached).toBe(true); expect(result.text).toBe(persisted.text); - // The refresh row re-keys the payload to today. - const refreshed = JSON.parse( - ( - vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { - data: { details: string }; - } - ).data.details, - ) as { dateKey: string; snapshotHash: string }; - expect(refreshed.snapshotHash).toBe(persisted.snapshotHash); + // The refresh re-dates the stored note to today and leaves the note + // and its fingerprint alone. + expect(prisma.insightStatusCache.upsert).not.toHaveBeenCalled(); + expect(prisma.insightStatusCache.updateMany).toHaveBeenCalledTimes(1); + const refreshed = ( + vi.mocked(prisma.insightStatusCache.updateMany).mock.calls[0][0] as { + data: Record; + } + ).data; + expect(refreshed).not.toHaveProperty("snapshotHash"); + expect(refreshed).not.toHaveProperty("textEncrypted"); expect(refreshed.dateKey).not.toBe("2026-06-09"); } finally { vi.useRealTimers(); @@ -292,21 +345,14 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { { type: "WEIGHT", value: 82, measuredAt: now }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ - createdAt: now, - details: JSON.stringify({ + vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue( + noteRow({ dateKey: "2026-06-09", - locale: "en", text: "Older assessment.", - providerType: "anthropic", - model: "x", - tokensUsed: 1, + generatedAt: now, snapshotHash: "f".repeat(64), - }), - } as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: now, - } as never); + }) as never, + ); stubCompletion('{"summary":"Fresh assessment for changed data."}'); const result = await generateWeightStatusForUser("user-1", { @@ -322,14 +368,10 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => { it("strips metric: tokens out of the cached text before persisting", async () => { - vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.measurement.findMany).mockResolvedValue([ { type: "WEIGHT", value: 82, measuredAt: new Date() }, ] as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.auditLog.create).mockResolvedValue({ - createdAt: new Date(), - } as never); stubCompletion( '{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}', @@ -341,11 +383,8 @@ describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", ( expect(result.text).toBeTruthy(); expect(result.text).not.toContain("metric:"); - const createCalls = vi.mocked(prisma.auditLog.create).mock.calls; - expect(createCalls.length).toBeGreaterThan(0); - const details = (createCalls[0][0] as { data: { details: string } }).data - .details; - const parsed = JSON.parse(details) as { text: string }; - expect(parsed.text).not.toContain("metric:"); + const notes = writtenNotes(prisma.insightStatusCache.upsert); + expect(notes.length).toBeGreaterThan(0); + expect(notes[0].text).not.toContain("metric:"); }); }); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index 9238b0920..58ba09cf4 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -211,9 +211,9 @@ export async function generateBiomarkerStatus(args: { metric: scope, locale, }); - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + if (outcome.kind === "unavailable") { return { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyBiomarkerStatusText(locale, marker.name), cached: true, updatedAt: null, @@ -324,7 +324,7 @@ export async function generateBiomarkerStatus(args: { const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, - consentSurface: "insights", + capability: "statusText", systemPrompt: getBiomarkerSystemPrompt(safeMarkerName, locale as Locale), userPrompt: getBiomarkerUserPrompt( snapshotJson, @@ -383,11 +383,7 @@ export async function generateBiomarkerStatus(args: { userId: args.userId, cacheAction, todayKey, - locale, text, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, inputHash, }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 378988eea..c1ad7db91 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -191,13 +191,13 @@ export async function prepareBloodPressureStatusForUser( metric: "blood-pressure", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyBloodPressureStatusText(locale), cached: true, updatedAt: null, @@ -769,11 +769,7 @@ export async function prepareBloodPressureStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); return { diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 1f88f8b96..0ad6d4a93 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -132,14 +132,13 @@ export async function prepareBmiStatusForUser( metric: "bmi", locale, }); - // v1.16.13 — `consent-missing` (provider configured but the - // server-managed consent gate blocks egress) serves the same no-key - // fallback; no enqueue happens for it (the resolver short-circuits). - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyBmiStatusText(locale), cached: true, updatedAt: null, @@ -460,11 +459,7 @@ export async function prepareBmiStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, // v1.18.11 (P6) — persist the input fingerprint for tomorrow's gate. inputHash, diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 2891697ab..2d89f3cdb 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -78,6 +78,7 @@ import { BriefingBudgetExceededError, runBriefingCompletion, } from "@/lib/insights/briefing-provider"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import { chainRequiresServerManagedConsent, hasActiveConsentForSurface, @@ -142,6 +143,8 @@ export type GenerateOutcome = | { status: "skipped"; reason: + /** The `briefing` capability is unavailable for another reason. */ + | "unavailable" | "no-provider" | "no-consent" | "budget" @@ -657,6 +660,27 @@ export async function generateComprehensiveInsight( return { status: "cached" }; } + // The `briefing` capability at the wire, before the chain is resolved (a + // Codex chain may refresh a token on resolve). Callers resolve it too (the + // route before it enqueues, the nightly pass before this call); switches, + // the AI analysis opt-out and consent can change in between. + const capability = await aiCapabilityForRecord(userId, "briefing"); + if (!capability.available) { + annotate({ + action: { name: "insights.comprehensive.unavailable" }, + meta: { reason: capability.reason }, + }); + return { + status: "skipped", + reason: + capability.reason === "no_provider" + ? "no-provider" + : capability.reason === "consent_required" + ? "no-consent" + : "unavailable", + }; + } + const chain = await resolveProviderChain(userId); if (chain.length === 0) { const legacy = await resolveProvider(userId); diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index 82bb9c24b..89c895df1 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -300,7 +300,7 @@ export async function generateDerivedScoreAssessment(args: { const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, - consentSurface: "insights", + capability: "statusText", systemPrompt: scoreSystemPrompt(locale), userPrompt: scoreUserPrompt( signal, @@ -399,11 +399,7 @@ export async function generateDerivedScoreAssessment(args: { userId: args.userId, cacheAction, todayKey, - locale, text, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, // v1.22 (W6) — store the input fingerprint so the next day's gate can skip // the warm when no contributor source changed. inputHash, diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index e294ed998..ed398a136 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -130,13 +130,13 @@ export async function prepareGeneralStatusForUser( metric: "general", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyGeneralStatusText(locale), cached: true, updatedAt: null, @@ -538,11 +538,7 @@ export async function prepareGeneralStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); return { diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 17236dd2d..a93a3c2af 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -37,9 +37,11 @@ import { round, } from "@/lib/insights/status-shared"; import { - isTimeoutStub, + readFreshStatusItems, + refreshUnchangedStatusItems, resolveReadOnlyStatusMiss, statusCacheAction, + writeStatusNote, } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; @@ -58,6 +60,17 @@ interface MedicationSummaryItem { text: string; } +/** The stored per-medication lines, keeping only well-formed entries. */ +function parseMedicationItems(items: unknown[]): MedicationSummaryItem[] { + return items.filter( + (entry): entry is MedicationSummaryItem => + typeof (entry as MedicationSummaryItem | null)?.medicationId === + "string" && + typeof (entry as MedicationSummaryItem | null)?.text === "string" && + (entry as MedicationSummaryItem).text.trim().length > 0, + ); +} + /** * The richer compliance result envelope (`summary` + per-medication * `medications`) the cards read — distinct from the standard `text`-only @@ -120,53 +133,25 @@ export async function prepareMedicationComplianceStatusForUser( const userTz = await resolveUserTimezone(userId); const todayKey = userDayKey(new Date(), userTz); - // This route carries a richer cached envelope (`summary` + - // `medications`) than the standard `text`-only generators, so it - // keeps its own cache-read — but it shares the stub-rejection - // predicate so a timeout stub never sticks for the day. + // This card stores per-medication lines next to its summary, so it reads + // its note through the items variant of the shared reader. if (!force) { - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, + const cached = await readFreshStatusItems({ + userId, + cacheAction, + todayKey, }); - if (latestCache?.details) { - try { - const parsed = JSON.parse(latestCache.details) as { - dateKey?: string; - summary?: string; - text?: string; - model?: string; - timeout?: boolean; - medications?: MedicationSummaryItem[]; - }; - - if ( - parsed.dateKey === todayKey && - !isTimeoutStub(parsed) && - typeof parsed.summary === "string" && - parsed.summary.trim().length > 0 && - Array.isArray(parsed.medications) - ) { - return { - phase: "served", - result: { - hasProvider: true, - summary: parsed.summary, - medications: parsed.medications.filter( - (entry): entry is MedicationSummaryItem => - typeof entry?.medicationId === "string" && - typeof entry?.text === "string" && - entry.text.trim().length > 0, - ), - cached: true, - updatedAt: latestCache.createdAt.toISOString(), - }, - }; - } - } catch { - // ignore invalid cache payload - } + if (cached && Array.isArray(cached.items)) { + return { + phase: "served", + result: { + hasProvider: true, + summary: cached.text, + medications: parseMedicationItems(cached.items), + cached: true, + updatedAt: cached.updatedAt, + }, + }; } } @@ -178,13 +163,13 @@ export async function prepareMedicationComplianceStatusForUser( metric: "medication-compliance", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason: the deterministic line, no enqueue. + // `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, summary: getNoKeyMedicationComplianceStatusText(locale), medications: [], cached: true, @@ -429,56 +414,26 @@ export async function prepareMedicationComplianceStatusForUser( // Content-hash gate (v1.16.8): when the snapshot is unchanged since the // last real assessment, refresh the cache timestamp and skip the LLM. - // This generator caches the richer `{ summary, medications }` envelope, - // so it carries its own gate rather than the shared text-only one. + // This card stores per-medication lines next to the summary, so it uses + // the items variant of the shared gate. const snapshotHash = hashInsightSnapshot(snapshot); - const latestRow = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { details: true }, + const unchanged = await refreshUnchangedStatusItems({ + userId, + cacheAction, + todayKey, + snapshotHash, }); - if (latestRow?.details) { - try { - const parsed = JSON.parse(latestRow.details) as { - summary?: string; - medications?: MedicationSummaryItem[]; - model?: string; - timeout?: boolean; - snapshotHash?: string; - }; - if ( - !isTimeoutStub(parsed) && - typeof parsed.summary === "string" && - parsed.summary.trim().length > 0 && - Array.isArray(parsed.medications) && - parsed.snapshotHash === snapshotHash - ) { - const refreshed = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ ...parsed, dateKey: todayKey }), - }, - select: { createdAt: true }, - }); - annotate({ - action: { name: "insights.status.skipped_unchanged" }, - meta: { cache_action: cacheAction }, - }); - return { - phase: "served", - result: { - hasProvider: true, - summary: parsed.summary, - medications: parsed.medications, - cached: true, - updatedAt: refreshed.createdAt.toISOString(), - }, - }; - } - } catch { - // Malformed cache payload — fall through to a real generation. - } + if (unchanged && Array.isArray(unchanged.items)) { + return { + phase: "served", + result: { + hasProvider: true, + summary: unchanged.text, + medications: parseMedicationItems(unchanged.items), + cached: true, + updatedAt: unchanged.updatedAt, + }, + }; } const previousContext = await getPreviousInsightContext( @@ -622,29 +577,20 @@ export async function prepareMedicationComplianceStatusForUser( "Medication-compliance-status summary was empty after normalization", ); } - const created = await prisma.auditLog.create({ - data: { - userId, - action: cacheAction, - details: JSON.stringify({ - dateKey: todayKey, - locale, - summary, - medications: medicationSummaries, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, - snapshotHash, - }), - }, - select: { createdAt: true }, + const generatedAt = await writeStatusNote({ + userId, + cacheAction, + todayKey, + text: summary, + items: medicationSummaries, + snapshotHash, }); return { hasProvider: true, summary, medications: medicationSummaries, cached: false, - updatedAt: created.createdAt.toISOString(), + updatedAt: generatedAt.toISOString(), }; }, }; diff --git a/src/lib/insights/memory.ts b/src/lib/insights/memory.ts index f58e1e15c..645018a33 100644 --- a/src/lib/insights/memory.ts +++ b/src/lib/insights/memory.ts @@ -2,9 +2,9 @@ * Insight memory — what the AI told this user last time. * * Every status generator (general / blood-pressure / weight / pulse / - * bmi / medication-compliance / mood) caches its rendered text in an - * `audit_logs` row keyed by `action = "insights.{scope}.{locale}"`. - * v1.4 turns that history into prompt context: the next generation + * bmi / medication-compliance / mood, and the generic metric cards) stores + * its note in `InsightStatusCache`, named `insights.{scope}.{locale}`. + * v1.4 turns that note into prompt context: the next generation * sees the previous classification + a short summary, so the model * can call out improvements and regressions instead of restating * status from scratch every day. @@ -17,14 +17,13 @@ * `compareSnapshots()` and renders ↑/↓/→ glyphs next to findings. */ -import { prisma } from "@/lib/db"; +import { readPreviousStatusNote } from "@/lib/insights/status-cache"; import { instructionLocale } from "@/lib/ai/prompts/output-language"; import type { SupportedLocale } from "@/lib/insights/status-shared"; /** * Stable identifiers for the seven insight scopes. The string MUST - * match the suffix in the existing audit-log `action` field so we - * read the same rows the cache already writes. + * match the note name the generators store under (`statusCacheAction`). */ export type InsightScope = | "general-status" @@ -59,10 +58,9 @@ export interface PreviousInsightContext { } /** - * Read the most recent cached insight for `userId` + `scope` + `locale` - * that is at least `minAgeHours` old. Returns null when no eligible - * snapshot exists (first-run users) or when the cache row's payload - * is malformed. + * Read the stored note for `userId` + `scope` + `locale` when it is at + * least `minAgeHours` old. Returns null when there is none (first-run + * users) or it was written more recently than that. * * The default 12-hour floor keeps the model from comparing today's * 09:00 reading to today's 06:00 reading and treating sub-day noise as @@ -78,47 +76,19 @@ export async function getPreviousInsightContext( const action = `insights.${scope}.${locale}`; const olderThan = new Date(Date.now() - minAgeHours * 60 * 60 * 1000); - const row = await prisma.auditLog.findFirst({ - where: { - userId, - action, - createdAt: { lt: olderThan }, - }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, + const row = await readPreviousStatusNote({ + userId, + cacheAction: action, + olderThan, }); + if (!row) return null; + const text = row.text; - if (!row?.details) return null; - - let text: string | null = null; - try { - const parsed = JSON.parse(row.details) as { - text?: unknown; - summary?: unknown; - }; - if (typeof parsed.text === "string" && parsed.text.trim().length > 0) { - text = parsed.text; - } else if ( - typeof parsed.summary === "string" && - parsed.summary.trim().length > 0 - ) { - text = parsed.summary; - } - } catch { - // Older cache rows stored raw text instead of JSON. Surface them - // verbatim so the prompt still benefits from the comparison. - if (row.details.trim().length > 0) { - text = row.details; - } - } - - if (!text) return null; - - const ageMs = Date.now() - row.createdAt.getTime(); + const ageMs = Date.now() - row.generatedAt.getTime(); const ageDays = Math.max(0, Math.floor(ageMs / (24 * 60 * 60 * 1000))); return { - generatedAt: row.createdAt.toISOString(), + generatedAt: row.generatedAt.toISOString(), ageDays, // Cap text so a verbose previous analysis cannot bloat the prompt. text: text.length > 1500 ? text.slice(0, 1500) + "…" : text, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index d9defd0d2..c165d9828 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -197,11 +197,11 @@ export async function generateMetricStatus(args: { metric: scope, locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyGeneralStatusText(locale), cached: true, updatedAt: null, @@ -500,7 +500,7 @@ export async function generateMetricStatus(args: { const outcome = await runStatusCompletion({ userId: args.userId, cacheAction, - consentSurface: "insights", + capability: "statusText", systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), userPrompt: getMetricArchetypeUserPrompt( meta, @@ -564,11 +564,7 @@ export async function generateMetricStatus(args: { userId: args.userId, cacheAction, todayKey, - locale, text, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index ec2dd6889..13d11a647 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -179,13 +179,13 @@ export async function prepareMoodStatusForUser( metric: "mood", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyMoodStatusText(locale), cached: true, updatedAt: null, @@ -768,11 +768,7 @@ export async function prepareMoodStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); return { diff --git a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts index 12fec86c8..21286fb9b 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-generate.test.ts @@ -15,6 +15,19 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); // prisma double, so this is only a guard against accidental real-DB use. vi.mock("@/lib/db", () => ({ prisma: {} })); +// The `periodNarrative` capability decides whether model-written prose may be +// served. Available unless a test says otherwise. +const { aiCapabilityForRecord } = vi.hoisted(() => ({ + aiCapabilityForRecord: vi.fn(), +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityForRecord })); + +const UNAVAILABLE = { + available: false, + reason: "user_disabled", + onDeviceAllowed: false, +} as const; + import { generatePeriodNarrative, readPeriodNarrative, @@ -107,7 +120,29 @@ function makePrisma(seedRow?: FakeRow) { }; } -beforeEach(() => vi.clearAllMocks()); +beforeEach(() => { + vi.clearAllMocks(); + aiCapabilityForRecord.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); +}); + +function seededRow(over: Partial = {}): FakeRow { + return { + userId: "u1", + period: "week", + locale: "en", + dateKey: "2026-05-15", + encryptedContent: new Uint8Array(Buffer.from("enc:old", "utf8")), + provenanceJson: null, + providerType: "openai", + promptVersion: NARRATIVE_PROMPT_VERSION, + updatedAt: new Date(), + ...over, + }; +} describe("buildNarrativeUserPrompt", () => { it("renders metrics, drivers, and the FDR footer with the prompt version", () => { @@ -150,16 +185,20 @@ describe("generatePeriodNarrative — descriptive generation", () => { it("passes a descriptive-never-causal system prompt to the provider", async () => { const prisma = makePrisma(); let systemPrompt = ""; - const runCompletion = vi.fn(async (args: { systemPrompt: string }) => { - systemPrompt = args.systemPrompt; - return { - kind: "ok" as const, - content: "ok", - providerType: "openai", - model: "m", - tokensUsed: 1, - }; - }); + let capability = ""; + const runCompletion = vi.fn( + async (args: { systemPrompt: string; capability: string }) => { + systemPrompt = args.systemPrompt; + capability = args.capability; + return { + kind: "ok" as const, + content: "ok", + providerType: "openai", + model: "m", + tokensUsed: 1, + }; + }, + ); await generatePeriodNarrative("u1", { period: "week", locale: "en", @@ -174,6 +213,8 @@ describe("generatePeriodNarrative — descriptive generation", () => { // v1.21.0 (coach C1 MEDIUM-1) — the retrospective now carries the shared // warm tone contract verbatim, matching the briefing's house voice. expect(systemPrompt).toContain(toneContract.en); + // The chokepoint re-checks this narrative's own capability at the wire. + expect(capability).toBe("periodNarrative"); }); }); @@ -208,7 +249,10 @@ describe("generatePeriodNarrative — honesty floor", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any prisma: prisma as any, buildContext: async () => readyContext() as PeriodNarrativeResult, - runCompletion: async () => ({ kind: "none" as const }), + runCompletion: async () => ({ + kind: "none" as const, + reason: "no_provider" as const, + }), }); expect(outcome).toEqual({ status: "generated", @@ -292,3 +336,81 @@ describe("generatePeriodNarrative — cache + regenerate", () => { expect(read?.providerType).toBe("anthropic"); }); }); + +describe("period narrative — the periodNarrative capability", () => { + it("readPeriodNarrative hides a model-written row while the capability is unavailable", async () => { + const prisma = makePrisma(seededRow({ providerType: "openai" })); + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + const read = await readPeriodNarrative( + "u1", + "week", + "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + ); + expect(read).toBeNull(); + expect(aiCapabilityForRecord).toHaveBeenCalledWith("u1", "periodNarrative"); + }); + + it("readPeriodNarrative still serves the deterministic narrative, which is data", async () => { + const prisma = makePrisma( + seededRow({ + providerType: "deterministic", + encryptedContent: new Uint8Array( + Buffer.from("enc:Your weight eased down.", "utf8"), + ), + }), + ); + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + const read = await readPeriodNarrative( + "u1", + "week", + "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + ); + expect(read?.text).toBe("Your weight eased down."); + expect(aiCapabilityForRecord).not.toHaveBeenCalled(); + }); + + it("does not short-circuit on a fresh model-written row it may not serve", async () => { + const prisma = makePrisma(seededRow({ providerType: "openai" })); + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + // The chokepoint refuses on the same capability, so the deterministic + // narrative replaces the hidden prose in place. + const runCompletion = vi.fn(async () => ({ + kind: "none" as const, + reason: "user_disabled" as const, + })); + const outcome = await generatePeriodNarrative("u1", { + period: "week", + locale: "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + buildContext: async () => readyContext() as PeriodNarrativeResult, + runCompletion, + }); + expect(outcome).toEqual({ + status: "generated", + providerType: "deterministic", + }); + expect(runCompletion).toHaveBeenCalledTimes(1); + expect(prisma._get()?.providerType).toBe("deterministic"); + }); + + it("still short-circuits on a fresh deterministic row whatever the capability says", async () => { + const prisma = makePrisma(seededRow({ providerType: "deterministic" })); + aiCapabilityForRecord.mockResolvedValue(UNAVAILABLE); + const runCompletion = vi.fn(); + const outcome = await generatePeriodNarrative("u1", { + period: "week", + locale: "en", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: prisma as any, + buildContext: async () => readyContext() as PeriodNarrativeResult, + runCompletion, + }); + expect(outcome).toEqual({ status: "cached" }); + expect(runCompletion).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index adacbf36f..89d4209df 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -32,6 +32,7 @@ import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets"; import { dayRotatedSeed } from "@/lib/ai/prompts/opener-archetype"; import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import { buildPeriodNarrativeContext, type NarrativePeriod, @@ -315,15 +316,19 @@ export async function generatePeriodNarrative( const runCompletion = options.runCompletion ?? runStatusCompletion; const period = options.period; - // Freshness short-circuit — a recently-generated row is served as-is. + // Freshness short-circuit — a recently-generated row is served as-is, as + // long as a reader may serve it. A model-written row whose capability has + // since become unavailable is hidden on read, so it counts as missing here + // and the deterministic narrative below takes its place. if (!force) { const existing = await prisma.insightNarrative.findUnique({ where: { userId_period_locale: { userId, period, locale } }, - select: { updatedAt: true }, + select: { updatedAt: true, providerType: true }, }); if ( existing && - now.getTime() - existing.updatedAt.getTime() < NARRATIVE_FRESH_MS + now.getTime() - existing.updatedAt.getTime() < NARRATIVE_FRESH_MS && + (await narrativeServable(userId, existing.providerType)) ) { return { status: "cached" }; } @@ -347,7 +352,7 @@ export async function generatePeriodNarrative( const completion = await runCompletion({ userId, cacheAction: `insights.narrative.${period}.${locale}`, - consentSurface: "insights", + capability: "periodNarrative", systemPrompt: buildNarrativeSystemPrompt(locale), userPrompt: buildNarrativeUserPrompt(context, bodyLocale), temperature: AI_BUDGETS.narrative.temperature, @@ -418,9 +423,10 @@ export async function generatePeriodNarrative( }); }; - // No usable provider → compose the deterministic, non-causal fallback from - // the same structured context, so the retrospective card is never empty for - // an active account (iOS H2). A later AI warm overwrites this row in place. + // No usable provider, or the `periodNarrative` capability is unavailable for + // any other reason → compose the deterministic, non-causal fallback from the + // same structured context, so the retrospective card is never empty for an + // active account (iOS H2). A later AI warm overwrites this row in place. if (completion.kind === "none") { const text = buildDeterministicNarrative(context, bodyLocale); await persist(text, DETERMINISTIC_PROVIDER_TYPE); @@ -453,7 +459,7 @@ export async function generatePeriodNarrative( const retry = await runCompletion({ userId, cacheAction: `insights.narrative.${period}.${locale}`, - consentSurface: "insights", + capability: "periodNarrative", systemPrompt: buildNarrativeSystemPrompt(locale), userPrompt: buildNarrativeUserPrompt(context, bodyLocale) + @@ -500,7 +506,8 @@ export interface NarrativeRead { /** * Read the latest narrative for `(userId, period, locale)`, decrypting the - * prose. Null when none was ever generated. This is the stale-while-revalidate + * prose. Null when none was ever generated, or when it is model-written and + * `periodNarrative` is unavailable for the record. This is the stale-while-revalidate * source: it returns whatever was last produced, regardless of age, so the * surface renders prior prose immediately while a refresh warms out of band. */ @@ -514,6 +521,15 @@ export async function readPeriodNarrative( where: { userId_period_locale: { userId, period, locale } }, }); if (!row) return null; + // Model-written prose is never served while the capability is unavailable, + // whatever the reason. The deterministic narrative is data and always is. + if (!(await narrativeServable(userId, row.providerType))) { + annotate({ + action: { name: "insights.narrative.hidden" }, + meta: { period, locale }, + }); + return null; + } let text: string; try { @@ -546,6 +562,19 @@ export async function readPeriodNarrative( // ── helpers ─────────────────────────────────────────────────────────────── +/** + * Whether a stored narrative may be served: always for the deterministic + * narrative, and for model-written prose only while `periodNarrative` is + * available for the record. + */ +async function narrativeServable( + userId: string, + providerType: string | null, +): Promise { + if (providerType === DETERMINISTIC_PROVIDER_TYPE) return true; + return (await aiCapabilityForRecord(userId, "periodNarrative")).available; +} + /** Prisma `Bytes` ↔ ciphertext, mirroring the CoachMessage helper. */ function encryptToBytes(plaintext: string): Uint8Array { const ciphertext = encrypt(plaintext); diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 8fe30a89c..a2efb912f 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -139,13 +139,13 @@ export async function preparePulseStatusForUser( metric: "pulse", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyPulseStatusText(locale), cached: true, updatedAt: null, @@ -509,11 +509,7 @@ export async function preparePulseStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, }); return { diff --git a/src/lib/insights/status-batch.ts b/src/lib/insights/status-batch.ts index 2afd0c5bf..a494ac3e7 100644 --- a/src/lib/insights/status-batch.ts +++ b/src/lib/insights/status-batch.ts @@ -18,7 +18,7 @@ * are omitted, never fabricated), and run ONE completion with one * JSON-correction retry. * 3. Fan each returned summary back into that metric's `finalize`, which - * persists the SAME per-metric `auditLog` cache row the standalone + * stores the SAME per-metric status note the standalone * generator wrote — so the card read path is unchanged. * * Graceful degradation (audit requirement): any metric the batch omits, or @@ -51,6 +51,7 @@ import { } from "@/lib/ai/prompts/status-batch"; import type { Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; /** The seven specialised prepares, in the warm order the cron used. */ const PREPARES: ReadonlyArray< @@ -121,6 +122,19 @@ export async function generateStatusBatchForUser( batchCallMade: false, }; + // The capability before any snapshot is built: someone whose status notes + // are unavailable (switch, AI analysis off, no provider, consent withdrawn) + // costs one capability read, not seven snapshot builds. The chokepoint + // re-checks it at the wire. + const capability = await aiCapabilityForJob(userId, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.batch.skipped" }, + meta: { reason: capability.reason }, + }); + return result; + } + // Build every card's snapshot once. A prepare that throws must not abort // the whole batch — that one metric is simply skipped this cycle. const prepared = await Promise.all( @@ -183,7 +197,7 @@ export async function generateStatusBatchForUser( let outcome = await runStatusCompletion({ userId, cacheAction: "insights.status-batch", - consentSurface: "insights", + capability: "statusText", systemPrompt, userPrompt, temperature: AI_BUDGETS.statusBatch.temperature, @@ -220,7 +234,7 @@ export async function generateStatusBatchForUser( const retry = await runStatusCompletion({ userId, cacheAction: "insights.status-batch", - consentSurface: "insights", + capability: "statusText", systemPrompt, userPrompt: `${userPrompt}\n\nYour previous response was not valid JSON matching { "perMetric": { ... } }. Reply with that JSON object ONLY — no prose, no markdown fences.`, temperature: AI_BUDGETS.statusBatch.temperature, diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 645b46724..126460113 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -1,9 +1,9 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { - hasUsableStatusProvider, - statusConsentBlocksGeneration, -} from "@/lib/insights/status-provider"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; +import { probeProviderPresence } from "@/lib/ai/provider"; import { enqueueStatusGeneration, type InsightStatusScope, @@ -44,75 +44,220 @@ const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] = ]) as MeasurementType[]; /** - * Shared cache-read for the seven `*-status.ts` insight generators. + * The status-note store: what a model wrote about one metric, for one person, + * in one language. * - * Every generator persists its assessment as an `auditLog` row keyed - * `insights.-status.` whose `details` JSON carries - * `{ dateKey, locale, text, providerType, model, tokensUsed }`. On the - * next mount the generator reads the most recent such row and, if it is - * still for today, serves it without re-hitting the provider. + * Every per-metric generator (the seven specialised cards, the generic + * `metric:` cards, the derived-score and biomarker assessments) keeps its + * note in `InsightStatusCache`, one row per `(user, metric, locale)`, the note + * itself encrypted at rest. Until v1.39.0 the notes were appended to + * `audit_logs` as plaintext JSON; the callers still name a note by the old + * cache-action string (`insights.-status.`, built only by + * `statusCacheAction`), and this module maps it onto the row. * - * The timeout fallback used to poison this read. When a provider call - * exceeded the status budget the route persisted a `model:"timeout-stub"` - * / `timeout:true` row carrying the generic no-key text under the SAME - * `text` field a real assessment uses. The cache-read only checked - * `dateKey === today && text` — so the stub looked like a valid - * assessment and stuck until midnight, hiding the real data-driven text - * for the rest of the day. + * A row carries two things that are updated independently: * - * `readFreshStatusText` is the one cache-read every standard generator - * shares. It rejects stubs explicitly so a single stall no longer pins - * the fallback for the day, and a fresh generation is attempted instead. + * - the note (`textEncrypted`, its `dateKey`, `generatedAt` and the two + * fingerprints), written when a generation succeeds or an unchanged-data + * run re-dates it; + * - a short negative-cache window (`retryAt`, `negativeReason`), written + * when a generation stalls, errs or is screened, so a stalled provider + * does not turn every page visit into another enqueue. Writing it never + * touches the note, so one stall cannot hide yesterday's text, and writing + * a note clears it. + * + * Nothing here serves a note while the `statusText` capability is unavailable + * for the record, whatever the reason: an operator switch, the person's AI + * opt-out, a missing provider, a withdrawn consent. The note stays stored (a + * switch flipped back costs nothing) unless consent withdrawal purges it. */ /** - * The single source of truth for the per-status cache-action shape. - * - * Every per-metric assessment is persisted as an `auditLog` row whose - * `action` is `insights.-status.`. The shape IS the cache - * key, so building it by hand in multiple places risks the same silent - * drift the queryKey factory guards against on the client. `statusCacheAction` - * is the one builder. + * The single builder of a status note's name. The shape is part of the + * contract between the generators, the queue and this store, so it is built + * in one place only. */ export function statusCacheAction(scope: string, locale: string): string { return `insights.${scope}-status.${locale}`; } -interface ParsedStatusCache { - dateKey?: string; - locale?: string; - text?: string; - summary?: string; - providerType?: string; - model?: string; - tokensUsed?: number | null; - timeout?: boolean; - /** v1.8.3 — ISO timestamp before which a timeout stub suppresses re-enqueue. */ - retryAt?: string; - /** Explicit generated/negative cache classification for new rows. */ - statusKind?: "generated" | "negative"; - /** Whether the producer may be retried at the time this row is read. */ - retryable?: boolean; - /** Generated rows expire once the caller's current day differs. */ - expiresAfterDateKey?: string; - /** Stable non-sensitive negative-cache reason code. */ - reason?: "timeout" | "error" | "screened"; - /** v1.16.8 — fingerprint of the data snapshot the text was generated from. */ - snapshotHash?: string; - /** v1.18.11 (P6) — cheap fingerprint of the salient inputs (count + newest). */ - inputHash?: string; +const CACHE_ACTION_SHAPE = /^insights\.(.+)-status\.([^.]+)$/; + +/** The row key behind a note's name, or `null` for a name of another shape. */ +function statusCacheKey( + cacheAction: string, +): { metric: string; locale: string } | null { + const match = CACHE_ACTION_SHAPE.exec(cacheAction); + if (!match) return null; + return { metric: match[1], locale: match[2] }; +} + +/** One stored row, decrypted. */ +interface StoredStatusNote { + /** The note, `null` when the row carries only a negative-cache window. */ + text: string | null; + /** Per-item notes (JSON), for a card that carries them. */ + items: unknown; + inputHash: string | null; + snapshotHash: string | null; + dateKey: string; + generatedAt: Date | null; + retryAt: Date | null; + negativeReason: string | null; +} + +function decryptOrNull(payload: Uint8Array | null): string | null { + if (!payload || payload.byteLength === 0) return null; + try { + return decryptFromBytes(payload); + } catch { + // A note encrypted under a key the host no longer holds is a cache miss; + // the next run writes it again. + return null; + } +} + +async function readStatusNote( + userId: string, + cacheAction: string, +): Promise { + const key = statusCacheKey(cacheAction); + if (!key) return null; + const row = await prisma.insightStatusCache.findUnique({ + where: { userId_metric_locale: { userId, ...key } }, + select: { + textEncrypted: true, + itemsEncrypted: true, + inputHash: true, + snapshotHash: true, + dateKey: true, + generatedAt: true, + retryAt: true, + negativeReason: true, + }, + }); + if (!row) return null; + const text = decryptOrNull(row.textEncrypted); + const itemsJson = decryptOrNull(row.itemsEncrypted); + let items: unknown = null; + if (itemsJson !== null) { + try { + items = JSON.parse(itemsJson); + } catch { + items = null; + } + } + return { + text: text !== null && text.trim().length > 0 ? text : null, + items, + inputHash: row.inputHash, + snapshotHash: row.snapshotHash, + dateKey: row.dateKey, + generatedAt: row.generatedAt, + retryAt: row.retryAt, + negativeReason: row.negativeReason, + }; } /** - * A cached row is a timeout stub when it carries the sentinel marker the - * timeout path writes. Either flag is sufficient — older stub rows may - * predate one of the two markers, so both are honoured. + * Whether stored status notes may be served, or re-dated, for this record. + * Every reader and both unchanged-data gates ask this first. */ -export function isTimeoutStub(parsed: { - model?: string; - timeout?: boolean; -}): boolean { - return parsed.model === "timeout-stub" || parsed.timeout === true; +async function statusTextServable(userId: string): Promise { + return (await aiCapabilityForRecord(userId, "statusText")).available; +} + +/** + * Write a note: a successful generation. Upserts the one row, clears any + * negative-cache window, and returns when the note was written. + */ +export async function writeStatusNote(args: { + userId: string; + cacheAction: string; + todayKey: string; + text: string; + /** Per-item notes for a card that carries them; serialised as JSON. */ + items?: unknown; + snapshotHash?: string; + inputHash?: string; +}): Promise { + const key = statusCacheKey(args.cacheAction); + if (!key) { + throw new Error(`Not a status note name: ${args.cacheAction}`); + } + const generatedAt = new Date(); + const note = { + textEncrypted: encryptToBytes(args.text), + itemsEncrypted: + args.items === undefined + ? null + : encryptToBytes(JSON.stringify(args.items)), + inputHash: args.inputHash ?? null, + snapshotHash: args.snapshotHash ?? null, + dateKey: args.todayKey, + generatedAt, + retryAt: null, + negativeReason: null, + }; + await prisma.insightStatusCache.upsert({ + where: { userId_metric_locale: { userId: args.userId, ...key } }, + create: { userId: args.userId, ...key, ...note }, + update: note, + }); + return generatedAt; +} + +/** + * Re-date the stored note under today's day key without touching the note or + * its fingerprints: an unchanged-data run. Returns the new timestamp. + */ +async function redateStatusNote( + userId: string, + cacheAction: string, + todayKey: string, +): Promise { + const key = statusCacheKey(cacheAction); + const generatedAt = new Date(); + if (key) { + // `updateMany`, not `update`: a note purged between the read and here (a + // consent withdrawal) is a no-op, not a thrown "record not found". + await prisma.insightStatusCache.updateMany({ + where: { userId, ...key }, + data: { + dateKey: todayKey, + generatedAt, + retryAt: null, + negativeReason: null, + }, + }); + } + return generatedAt; +} + +/** + * Open a negative-cache window for a note: the generation stalled, erred, or + * was screened. Leaves any stored note exactly as it was. + */ +export async function writeStatusNegativeWindow(args: { + userId: string; + cacheAction: string; + todayKey: string; + reason: "timeout" | "error" | "screened"; + retryAt: Date; +}): Promise { + const key = statusCacheKey(args.cacheAction); + if (!key) return; + await prisma.insightStatusCache.upsert({ + where: { userId_metric_locale: { userId: args.userId, ...key } }, + create: { + userId: args.userId, + ...key, + dateKey: args.todayKey, + retryAt: args.retryAt, + negativeReason: args.reason, + }, + update: { retryAt: args.retryAt, negativeReason: args.reason }, + }); } export interface FreshStatusCacheHit { @@ -124,14 +269,12 @@ export interface FreshStatusCacheHit { } /** - * Read the latest cached assessment for `(userId, cacheAction)` and - * return its text only when it is (a) for today and (b) NOT a timeout - * stub. Returns `null` on a miss, a stale day, a stub, or a malformed - * payload — every one of those means the caller should regenerate. + * Today's note for `(userId, cacheAction)`, or `null` on a miss, a note from + * an earlier day, no note at all, or a capability that is unavailable. Every + * one of those means the caller should not serve stored text as current. * - * `force` short-circuits to `null` so a forced regeneration never reads - * the cache. The DB read is still skipped entirely under `force` to - * keep the forced path cheap. + * `force` short-circuits to `null` without a read, so a forced regeneration + * never reads the cache. */ export async function readFreshStatusText(args: { userId: string; @@ -141,61 +284,57 @@ export async function readFreshStatusText(args: { }): Promise { const { userId, cacheAction, todayKey, force } = args; if (force) return null; + if (!(await statusTextServable(userId))) return null; - const latestCache = await prisma.auditLog.findFirst({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true, details: true }, - }); - if (!latestCache?.details) return null; - - try { - const parsed = JSON.parse(latestCache.details) as ParsedStatusCache; - if (parsed.dateKey !== todayKey) return null; - if (isTimeoutStub(parsed)) return null; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - return null; - } - return { - kind: "generated", - text: parsed.text, - updatedAt: latestCache.createdAt.toISOString(), - retryable: false, - expiresAfterDateKey: parsed.expiresAfterDateKey ?? parsed.dateKey, - }; - } catch { - // Malformed cache payload — treat as a miss and regenerate. + const note = await readStatusNote(userId, cacheAction); + if (!note?.text || note.dateKey !== todayKey || !note.generatedAt) { return null; } + return { + kind: "generated", + text: note.text, + updatedAt: note.generatedAt.toISOString(), + retryable: false, + expiresAfterDateKey: note.dateKey, + }; } /** - * v1.16.8 — the content-hash regeneration gate for the per-status and - * generic metric cards. + * Today's per-item note for a card that carries one (medication compliance): + * the summary plus the parsed items, under the same rules as + * `readFreshStatusText`. + */ +export async function readFreshStatusItems(args: { + userId: string; + cacheAction: string; + todayKey: string; +}): Promise<{ text: string; items: unknown; updatedAt: string } | null> { + if (!(await statusTextServable(args.userId))) return null; + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || note.dateKey !== args.todayKey || !note.generatedAt) { + return null; + } + return { + text: note.text, + items: note.items, + updatedAt: note.generatedAt.toISOString(), + }; +} + +/** + * The content-hash regeneration gate for the per-status and generic metric + * cards. * - * A generator that has already gathered its data snapshot calls this - * BEFORE the provider round-trip. When the latest cached assessment for - * `(userId, cacheAction)` is a real (non-stub) text whose stored - * `snapshotHash` equals the fresh snapshot's hash, nothing the prompt - * sees has changed — so the gate re-persists the same text under - * today's `dateKey` (a pure timestamp refresh that keeps the read path - * and the ingest debounce satisfied) and returns it, and the caller - * skips the LLM call entirely. Returns `null` on any miss (no prior - * row, stub, empty text, missing or differing hash) — every one of - * those means the caller should generate for real. + * A generator that has already gathered its data snapshot calls this BEFORE + * the provider round-trip. When the stored note's `snapshotHash` equals the + * fresh snapshot's, nothing the prompt sees has changed: the note is re-dated + * under today's key and returned, and the caller skips the model entirely. + * `null` on any miss (no note, a missing or differing hash). * - * This single gate is what turns the nightly warm, the forced warm, and - * the ingest-driven regeneration into no-ops on unchanged data: each of - * those paths forces past the same-day cache read, gathers the - * snapshot, and lands here. - * - * Consent comes BEFORE the hash compare (mirroring the comprehensive - * pipeline's consent-before-gate order): re-stamping a cached text - * under today's `dateKey` presents it as a current AI assessment, so a - * user who revoked the server-managed AI consent must not have old - * text re-dated by an unchanged-data refresh. On a blocked consent the - * gate misses, the generator proceeds to `runStatusCompletion`, and - * that gate returns the no-key fallback without persisting anything. + * The capability comes first. Re-dating a note presents it as current, so a + * record whose `statusText` capability is unavailable (consent withdrawn, + * AI analysis switched off, …) never has old text re-dated; the gate misses, + * and the generator's own chokepoint then refuses the generation. */ export async function refreshUnchangedStatusInsight(args: { userId: string; @@ -203,57 +342,64 @@ export async function refreshUnchangedStatusInsight(args: { todayKey: string; snapshotHash: string; }): Promise { - if (await statusConsentBlocksGeneration(args.userId, "insights")) { + if (!(await statusTextServable(args.userId))) { annotate({ - action: { name: "insights.status.consent_required" }, + action: { name: "insights.status.unavailable" }, meta: { cache_action: args.cacheAction, gate: "unchanged-refresh" }, }); return null; } - const latest = await prisma.auditLog.findFirst({ - where: { userId: args.userId, action: args.cacheAction }, - orderBy: { createdAt: "desc" }, - select: { details: true }, - }); - if (!latest?.details) return null; + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || note.snapshotHash !== args.snapshotHash) return null; - let parsed: ParsedStatusCache; - try { - parsed = JSON.parse(latest.details) as ParsedStatusCache; - } catch { - return null; - } - if (isTimeoutStub(parsed)) return null; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - return null; - } - if (parsed.snapshotHash !== args.snapshotHash) return null; - - // Same data, valid text — refresh the cache row's day key so the - // read path serves it as today's assessment and the ingest debounce - // window restarts, without touching the provider. - const created = await prisma.auditLog.create({ - data: { - userId: args.userId, - action: args.cacheAction, - details: JSON.stringify({ ...parsed, dateKey: args.todayKey }), - }, - select: { createdAt: true }, - }); + const generatedAt = await redateStatusNote( + args.userId, + args.cacheAction, + args.todayKey, + ); annotate({ action: { name: "insights.status.skipped_unchanged" }, meta: { cache_action: args.cacheAction }, }); return { kind: "generated", - text: parsed.text, - updatedAt: created.createdAt.toISOString(), + text: note.text, + updatedAt: generatedAt.toISOString(), retryable: false, expiresAfterDateKey: args.todayKey, }; } +/** + * The same gate for a card that carries per-item notes: re-dates the stored + * note when its `snapshotHash` matches and hands back the items too. + */ +export async function refreshUnchangedStatusItems(args: { + userId: string; + cacheAction: string; + todayKey: string; + snapshotHash: string; +}): Promise<{ text: string; items: unknown; updatedAt: string } | null> { + if (!(await statusTextServable(args.userId))) return null; + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || note.snapshotHash !== args.snapshotHash) return null; + const generatedAt = await redateStatusNote( + args.userId, + args.cacheAction, + args.todayKey, + ); + annotate({ + action: { name: "insights.status.skipped_unchanged" }, + meta: { cache_action: args.cacheAction }, + }); + return { + text: note.text, + items: note.items, + updatedAt: generatedAt.toISOString(), + }; +} + /** * v1.18.11 (P6) — cheap input fingerprint for the slow-moving status * metrics (weight / BMI). @@ -393,21 +539,17 @@ export async function computeStatusInputFingerprint(args: { } /** - * v1.18.11 (P6) — the INPUT gate for slow-moving status metrics. + * The INPUT gate for slow-moving status metrics. * - * Runs BEFORE the snapshot build. When the latest cached assessment is a - * real (non-stub) text whose stored `inputHash` equals the freshly probed - * one, nothing the prompt could see has changed, so the gate re-stamps that - * text under today's `dateKey` and returns it — the caller then skips the - * whole gather AND the provider call. Returns `null` on any miss (no prior - * row, stub, empty text, missing or differing `inputHash`, or a forced - * regeneration), in which case the caller proceeds to the normal build + - * the finer post-build content-hash gate. + * Runs BEFORE the snapshot build. When the stored note's `inputHash` equals + * the freshly probed one, nothing the prompt could see has changed, so the + * note is re-dated under today's key and returned; the caller then skips the + * whole gather AND the provider call. `null` on any miss (no note, a missing + * or differing `inputHash`, a forced regeneration), in which case the caller + * proceeds to the normal build and the finer post-build content-hash gate. * - * Consent is checked first, mirroring `refreshUnchangedStatusInsight`: - * re-dating a cached text presents it as current, so a user who revoked the - * server-managed AI consent must never have stale text re-stamped by an - * unchanged-input refresh. + * The capability is checked first, for the reason `refreshUnchangedStatusInsight` + * gives. */ export async function gateUnchangedStatusInput(args: { userId: string; @@ -418,52 +560,34 @@ export async function gateUnchangedStatusInput(args: { }): Promise { if (args.force) return null; - if (await statusConsentBlocksGeneration(args.userId, "insights")) { + if (!(await statusTextServable(args.userId))) { annotate({ - action: { name: "insights.status.consent_required" }, + action: { name: "insights.status.unavailable" }, meta: { cache_action: args.cacheAction, gate: "unchanged-input" }, }); return null; } - const latest = await prisma.auditLog.findFirst({ - where: { userId: args.userId, action: args.cacheAction }, - orderBy: { createdAt: "desc" }, - select: { details: true }, - }); - if (!latest?.details) return null; - - let parsed: ParsedStatusCache; - try { - parsed = JSON.parse(latest.details) as ParsedStatusCache; - } catch { + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || !note.inputHash || note.inputHash !== args.inputHash) { return null; } - if (isTimeoutStub(parsed)) return null; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - return null; - } - if (!parsed.inputHash || parsed.inputHash !== args.inputHash) return null; - // Same inputs, valid text — re-stamp the day key WITHOUT rebuilding the - // snapshot or calling the provider. Preserve every field (incl. the prior - // `snapshotHash` + `inputHash`) so the next day's gates still match. - const created = await prisma.auditLog.create({ - data: { - userId: args.userId, - action: args.cacheAction, - details: JSON.stringify({ ...parsed, dateKey: args.todayKey }), - }, - select: { createdAt: true }, - }); + // Same inputs, a stored note: re-date it WITHOUT rebuilding the snapshot or + // calling the provider. Both fingerprints stay, so tomorrow's gates match. + const generatedAt = await redateStatusNote( + args.userId, + args.cacheAction, + args.todayKey, + ); annotate({ action: { name: "insights.status.skipped_unchanged" }, meta: { cache_action: args.cacheAction, gate: "input" }, }); return { kind: "generated", - text: parsed.text, - updatedAt: created.createdAt.toISOString(), + text: note.text, + updatedAt: generatedAt.toISOString(), retryable: false, expiresAfterDateKey: args.todayKey, }; @@ -475,70 +599,94 @@ export interface LastGoodStatusHit { } /** - * v1.8.7 — read the most recent NON-stub assessment for `(userId, - * cacheAction)` regardless of which day it was generated. This is the - * stale-while-revalidate source: when today's cache is a miss the - * read-only path can still serve yesterday's (or older) good text - * instantly while a fresh generation is warmed out of band, so opening a - * category never drops to the "preparing" skeleton if an assessment was - * ever produced. Returns `null` only when there is genuinely no prior - * assessment (or every prior row is a timeout stub / malformed). + * The stored note for `(userId, cacheAction)` whatever day it was written: + * the stale-while-revalidate source. When today's note is a miss the read path + * can still show the last one instantly while a fresh generation is warmed, + * so opening a category never drops to the "preparing" skeleton if a note + * was ever written. `null` when there is none, or when the capability is + * unavailable. */ export async function readLastGoodStatusText(args: { userId: string; cacheAction: string; }): Promise { - const { userId, cacheAction } = args; - const rows = await prisma.auditLog.findMany({ - where: { userId, action: cacheAction }, - orderBy: { createdAt: "desc" }, - take: 5, - select: { createdAt: true, details: true }, - }); - for (const row of rows) { - if (!row.details) continue; - try { - const parsed = JSON.parse(row.details) as ParsedStatusCache; - if (isTimeoutStub(parsed)) continue; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - continue; - } - return { text: parsed.text, updatedAt: row.createdAt.toISOString() }; - } catch { - // Malformed payload — skip and look further back. - continue; - } + if (!(await statusTextServable(args.userId))) return null; + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || !note.generatedAt) return null; + return { text: note.text, updatedAt: note.generatedAt.toISOString() }; +} + +/** + * The stored note written at least `minAgeHours` ago, for the next + * generation's "what did I say last time" prompt block. Not a serving read: + * it feeds a generation that the chokepoint has already admitted. + */ +export async function readPreviousStatusNote(args: { + userId: string; + cacheAction: string; + olderThan: Date; +}): Promise<{ text: string; generatedAt: Date } | null> { + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.text || !note.generatedAt) return null; + if (note.generatedAt.getTime() >= args.olderThan.getTime()) return null; + return { text: note.text, generatedAt: note.generatedAt }; +} + +/** + * Which of `cacheActions` carry a note written at or after `since`. The + * ingest invalidator skips those, so a fresh note survives a sync drip. + */ +export async function statusNotesWrittenSince(args: { + userId: string; + cacheActions: readonly string[]; + since: Date; +}): Promise> { + const byKey = new Map(); + for (const action of args.cacheActions) { + const key = statusCacheKey(action); + if (key) byKey.set(`${key.metric}\u0000${key.locale}`, action); } - return null; + if (byKey.size === 0) return new Set(); + const keys = [...byKey.keys()].map((k) => { + const [metric, locale] = k.split("\u0000"); + return { metric, locale }; + }); + const rows = await prisma.insightStatusCache.findMany({ + where: { + userId: args.userId, + OR: keys, + generatedAt: { gte: args.since }, + textEncrypted: { not: null }, + }, + select: { metric: true, locale: true }, + }); + const fresh = new Set(); + for (const row of rows) { + const action = byKey.get(`${row.metric}\u0000${row.locale}`); + if (action) fresh.add(action); + } + return fresh; } /** * Outcome of the read-only cache-miss resolution. The generators map this * onto their public return shape: - * - `no-provider` → `{ hasProvider: false, text: }` - * - `preparing` → `{ hasProvider: true, text: , preparing: true }` * - * v1.8.7 — `preparing` now carries the last good assessment (if any) so - * the card renders the previous text immediately (stale-while-revalidate) - * instead of a skeleton while the worker re-warms the cache. - * - * v1.9.0 — `revalidating` is true only when last-good text is served AND a - * fresh generation was actually enqueued (the open-card refresh case). When - * the served text is terminal the card otherwise stops polling and never - * sees the freshly-warmed assessment until a remount; the flag keeps the - * bounded poll alive until the new row lands. It is false on the - * suppressed-stub branch (no enqueue, so nothing is in flight) and whenever - * there is no last-good text (that path already polls via `preparing`). + * - `unavailable` → the deterministic line, no enqueue. `hasProvider` is + * provider presence and nothing else, so a withdrawn consent or a switch + * turned off no longer reads as "no provider configured"; `reason` says + * which layer said no. + * - `preparing` → a generation is (or recently was) in flight; the last + * note, if any, is served meanwhile. `revalidating` is true only when a + * last note is served AND a fresh generation was actually enqueued, so + * an open card keeps polling until the new note lands. */ export type ReadOnlyMissOutcome = - | { kind: "no-provider" } - // v1.16.13 — a provider IS configured, but it resolves via the operator's - // server-managed key and the user has no active consent receipt, so the - // generation gate (`assertConsentForChain` / `runStatusCompletion`) would - // serve the no-key fallback. Distinct from `no-provider` so the status DTO - // can render an honest "consent required" signal instead of conflating it - // with "no AI configured at all". - | { kind: "consent-missing" } + | { + kind: "unavailable"; + reason: AiUnavailableReason; + hasProvider: boolean; + } | { kind: "preparing"; lastGood: LastGoodStatusHit | null; @@ -546,34 +694,39 @@ export type ReadOnlyMissOutcome = }; /** - * v1.8.3 — resolve what a read-only status generation should return on a - * cache miss WITHOUT running the heavy SQL gather or the blocking LLM - * round-trip. This is the core of the holistic freeze fix: a navigation - * request must never await an uncapped provider call. + * Resolve what a read-only status read returns on a cache miss WITHOUT the + * heavy SQL gather or a blocking model round-trip: a navigation request never + * awaits a provider. * - * On a miss the route either: - * - finds no usable provider → returns `no-provider` (the card shows the - * no-key fallback; nothing to generate), or - * - finds a provider → fire-and-forget enqueues a generation job and - * returns `preparing` (the card shows a preparing state and the client - * polls until the worker warms the cache). - * - * The provider probe is a cheap chain-resolve (no completion), so the GET - * stays sub-second even on a cold cache. + * The `statusText` capability decides first. Unavailable, for any reason, + * means no enqueue and no stored text. Available means: serve the last note + * (if any) and enqueue a generation out of band, unless a recent stall opened + * a negative-cache window. */ export async function resolveReadOnlyStatusMiss(args: { userId: string; metric: InsightStatusScope; locale: SupportedLocale; }): Promise { - // v1.37.0 — the one gate on the delegated path, and it sits here rather - // than in the ten generators that reach this function precisely because it - // is one gate: a generator added next year inherits it without knowing it - // exists. A manager holding a MANAGE grant may READ the record's generated - // assessments; the miss behind that read must not ship the owner's data to - // the owner's provider on the owner's budget. Served the same way the - // negative-cache branch below serves it: last-good text if there is any, - // `preparing` if there is not, and nothing enqueued either way. + const capability = await aiCapabilityForRecord(args.userId, "statusText"); + if (!capability.available) { + const reason = capability.reason ?? "check_failed"; + annotate({ + action: { name: "insights.status.unavailable" }, + meta: { metric: args.metric, reason, read_only_miss: true }, + }); + return { + kind: "unavailable", + reason, + hasProvider: await probeProviderPresence(args.userId, "text"), + }; + } + + // v1.37.0 — a manager holding a MANAGE grant may READ the record's notes; + // the miss behind that read must not ship the owner's data to the owner's + // provider on the owner's budget. The last note if there is one, + // `preparing` if not, and nothing enqueued either way. One gate here rather + // than in every generator, so a generator added later inherits it. if (delegatedGenerationSuppressed()) { const lastGood = await readLastGoodStatusText({ userId: args.userId, @@ -590,39 +743,14 @@ export async function resolveReadOnlyStatusMiss(args: { return { kind: "preparing", lastGood, revalidating: false }; } - const hasProvider = await hasUsableStatusProvider(args.userId); - if (!hasProvider) return { kind: "no-provider" }; - - // v1.16.13 — a provider is configured but the server-managed consent gate - // would block egress (no active receipt for the surface). Surface this as - // a distinct outcome so the card renders an honest "consent required" - // signal rather than the generic no-key fallback. Enqueuing here would be - // wasted work — the generator's own gate would short-circuit to `none`. - if (await statusConsentBlocksGeneration(args.userId, "insights")) { - annotate({ - action: { name: "insights.status.consent_required" }, - meta: { metric: args.metric, read_only_miss: true }, - }); - return { kind: "consent-missing" }; - } - const cacheAction = statusCacheAction(args.metric, args.locale); - - // v1.8.7 — stale-while-revalidate. Surface the last good (non-stub) - // assessment for this scope so the card renders the previous text - // immediately instead of a skeleton while a refresh is warmed. Null when - // no assessment was ever produced — only then does the card show - // "preparing"/"no analysis yet". const lastGood = await readLastGoodStatusText({ userId: args.userId, cacheAction, }); - // v1.8.3 — honour the short-TTL negative cache. If the worker recently - // hit a provider stall it wrote a `retryAt` stub; re-enqueuing on every - // navigation while the provider is still degraded would be a storm. While - // the stub is fresh, stay in `preparing` without enqueuing; once it goes - // stale (or never existed) enqueue a fresh generation. + // Honour the short negative-cache window: re-enqueuing on every navigation + // while the provider is still degraded would be a storm. const negativeCache = await readStatusNegativeCache({ userId: args.userId, cacheAction, @@ -632,14 +760,11 @@ export async function resolveReadOnlyStatusMiss(args: { action: { name: "insights.status.preparing" }, meta: { metric: args.metric, suppressed_enqueue: true }, }); - // No enqueue on this branch — nothing is in flight, so the open card has - // nothing to revalidate against. It still polls via `preparing` when - // there is no last-good text to show. return { kind: "preparing", lastGood, revalidating: false }; } - // Enqueue out of band — do NOT await an LLM here. The enqueue itself is - // best-effort and de-duped per (user, metric, locale). + // Enqueue out of band, never await a model here. Best-effort and de-duped + // per (user, metric). await enqueueStatusGeneration({ userId: args.userId, metric: args.metric, @@ -649,20 +774,10 @@ export async function resolveReadOnlyStatusMiss(args: { action: { name: "insights.status.preparing" }, meta: { metric: args.metric, stale_served: lastGood !== null }, }); - // A fresh generation is now in flight. When last-good text is served the - // payload is otherwise terminal (`preparing` is false), so the open card - // would stop polling and never pick up the warmed assessment. Signal - // `revalidating` so the bounded poll stays alive until the new row lands. return { kind: "preparing", lastGood, revalidating: lastGood !== null }; } -/** - * True when the most recent cache row for `(userId, cacheAction)` is a - * timeout stub whose `retryAt` is still in the future. Used by the - * read-only resolver to suppress a re-enqueue storm while a provider is - * degraded. A stub without `retryAt` (legacy) is treated as stale so the - * resolver retries, matching the pre-v1.8.3 "transient miss" behaviour. - */ +/** An open negative-cache window on a note. */ export interface StatusNegativeCache { kind: "negative"; reason: "timeout" | "error" | "screened"; @@ -671,38 +786,23 @@ export interface StatusNegativeCache { } /** - * Read the latest negative-cache policy without exposing provider errors or - * assessment content. `retryable` flips only when the explicit `retryAt` - * boundary has passed; legacy/malformed stubs are treated as absent so the - * next request may recover. + * The note's negative-cache window, without exposing provider errors or note + * content. `retryable` flips once `retryAt` has passed. */ export async function readStatusNegativeCache(args: { userId: string; cacheAction: string; }): Promise { - const latest = await prisma.auditLog.findFirst({ - where: { userId: args.userId, action: args.cacheAction }, - orderBy: { createdAt: "desc" }, - select: { details: true }, - }); - if (!latest?.details) return null; - try { - const parsed = JSON.parse(latest.details) as ParsedStatusCache; - if (!isTimeoutStub(parsed)) return null; - if (typeof parsed.retryAt !== "string") return null; - const retryAtMs = new Date(parsed.retryAt).getTime(); - if (!Number.isFinite(retryAtMs)) return null; - const reason = - parsed.reason === "error" || parsed.reason === "screened" - ? parsed.reason - : "timeout"; - return { - kind: "negative", - reason, - retryAt: parsed.retryAt, - retryable: retryAtMs <= Date.now(), - }; - } catch { - return null; - } + const note = await readStatusNote(args.userId, args.cacheAction); + if (!note?.retryAt) return null; + const reason = + note.negativeReason === "error" || note.negativeReason === "screened" + ? note.negativeReason + : "timeout"; + return { + kind: "negative", + reason, + retryAt: note.retryAt.toISOString(), + retryable: note.retryAt.getTime() <= Date.now(), + }; } diff --git a/src/lib/insights/status-card-generation.ts b/src/lib/insights/status-card-generation.ts index e2e6e3d93..4439c7552 100644 --- a/src/lib/insights/status-card-generation.ts +++ b/src/lib/insights/status-card-generation.ts @@ -32,7 +32,7 @@ * for falls back to its own single-card path, never crashing the cycle. * * The cache + read contract is untouched: `finalize` persists the SAME - * `auditLog` row (`persistStatusInsight`, or the richer medication-compliance + * status note (`persistStatusInsight`, or the per-medication items * shape via a custom closure) the standalone generator wrote, so every card * keeps reading per-metric results with the same keys. */ @@ -125,7 +125,7 @@ export async function runPreparedStatusCard( const outcome: StatusProviderResult = await runStatusCompletion({ userId: prepared.userId, cacheAction: prepared.cacheAction, - consentSurface: "insights", + capability: "statusText", systemPrompt: prepared.systemPrompt, userPrompt: prepared.userPrompt, temperature: prepared.temperature, diff --git a/src/lib/insights/status-invalidation.ts b/src/lib/insights/status-invalidation.ts index 977dea890..373e50df7 100644 --- a/src/lib/insights/status-invalidation.ts +++ b/src/lib/insights/status-invalidation.ts @@ -20,12 +20,16 @@ import { normalizeLocale, type SupportedLocale, } from "@/lib/insights/status-shared"; -import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache"; +import { + statusCacheAction, + statusNotesWrittenSince, +} from "@/lib/insights/status-cache"; import { metricIdForMeasurementType, metricStatusScope, } from "@/lib/insights/metric-status-registry"; import { annotate } from "@/lib/logging/context"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; import type { MeasurementType } from "@/generated/prisma/client"; /** @@ -146,6 +150,17 @@ export async function invalidateStatusInsightsForTypes( } if (scopes.size === 0) return; + // No enqueue while status notes are unavailable for this record: the worker + // would refuse anyway, and the queue should not carry work nobody may do. + const capability = await aiCapabilityForRecord(userId, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.invalidate.skipped" }, + meta: { reason: capability.reason }, + }); + return; + } + // v1.8.7 — regenerate only the user's resolved locale, matching the // read-path (every `*-status` GET serves `normalizeLocale(user.locale)`). // Warming both locales doubled provider spend on every sync, half of it @@ -223,6 +238,14 @@ export async function enqueueStatusRefillForUser( userId: string, locale: SupportedLocale, ): Promise { + const capability = await aiCapabilityForRecord(userId, "statusText"); + if (!capability.available) { + annotate({ + action: { name: "insights.status.refill.skipped" }, + meta: { reason: capability.reason }, + }); + return 0; + } const scopes = new Set(PER_STATUS_SCOPES); try { // `groupBy` compiles to a server-side `GROUP BY` — Prisma's @@ -252,11 +275,10 @@ export async function enqueueStatusRefillForUser( * real (non-stub) assessment. Those scopes are skipped by the ingest * invalidator so a fresh assessment survives the sync drip. * - * One indexed read per user (a single `findMany` over this user's recent - * status-cache rows, newest-first) answers it for every candidate scope at - * once — cheaper than a per-scope probe and bounded by `take`. A timeout - * stub never counts as fresh (it carries no real assessment), so a scope - * that recently stalled still gets a retry enqueued. + * One indexed read per user answers it for every candidate scope at once. + * Only a stored note counts: a row that carries nothing but a negative-cache + * window is not fresh, so a scope that recently stalled still gets a retry + * enqueued. */ async function findRecentlyWarmedScopes( userId: string, @@ -273,37 +295,15 @@ async function findRecentlyWarmedScopes( actionToScope.set(statusCacheAction(scope, locale), scope); } - const rows = await prisma.auditLog.findMany({ - where: { - userId, - action: { in: candidateActions }, - createdAt: { gte: cutoff }, - }, - orderBy: { createdAt: "desc" }, - select: { action: true, details: true }, + const written = await statusNotesWrittenSince({ + userId, + cacheActions: candidateActions, + since: cutoff, }); - const fresh = new Set(); - for (const row of rows) { - const scope = actionToScope.get(row.action); - if (!scope || fresh.has(scope)) continue; - if (!row.details) continue; - try { - const parsed = JSON.parse(row.details) as { - model?: string; - timeout?: boolean; - text?: string; - }; - // A stub is not a real assessment — let a stalled scope retry. - if (isTimeoutStub(parsed)) continue; - if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { - continue; - } - fresh.add(scope); - } catch { - // Malformed payload — treat as not-fresh so the scope refreshes. - continue; - } + for (const action of written) { + const scope = actionToScope.get(action); + if (scope) fresh.add(scope); } return fresh; } diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index bb187ab5d..0d7185b4c 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -1,4 +1,13 @@ -import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; +import { + probeProviderPresence, + resolveProvider, + resolveProviderChain, +} from "@/lib/ai/provider"; +import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate"; +import type { + AiCapabilityKey, + AiUnavailableReason, +} from "@/lib/ai/capabilities/types"; import { runRawCompletionWithFallback, type ProviderChainResolved, @@ -42,7 +51,12 @@ import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; */ export type StatusProviderResult = - | { kind: "none" } + /** + * Nothing was sent: no provider, or the capability is unavailable for this + * record (operator switch, opt-out, module, consent, …). Callers serve the + * deterministic line and persist nothing; `reason` says which layer said no. + */ + | { kind: "none"; reason: AiUnavailableReason } | { kind: "timeout" } | { kind: "error" } | { @@ -74,15 +88,19 @@ interface RunStatusCompletionArgs { */ responseFormat?: "json" | "text"; /** - * v1.12.1 — which AI surface this generation serves, for the consent gate. - * `insights` for the per-metric status cards + period narrative; `coach` - * for the off-budget Coach memory workers (rolling summary + fact - * extraction). When the resolved chain would egress via the operator's - * server-managed key and no active receipt of the matching kind exists, - * the run short-circuits to `{ kind: "none" }` (the no-key fallback) so no - * PHI leaves the server. BYOK / local / ChatGPT-OAuth chains are ungated. + * The AI capability this generation serves (`statusText`, `periodNarrative`, + * `workoutInsights`, `coach`, …). Required: the chokepoint re-checks it + * immediately before anything leaves the machine, so a job enqueued before + * an operator flipped a switch, or before the person withdrew consent or + * turned AI analysis off, still stops here. The capability also names the + * consent kinds the server-managed chain needs. */ - consentSurface: ConsentSurface; + capability: AiCapabilityKey; +} + +/** The consent surface a capability's server-managed egress is gated on. */ +function consentSurfaceFor(capability: AiCapabilityKey): ConsentSurface { + return capability === "coach" ? "coach" : "insights"; } /** @@ -103,42 +121,14 @@ async function resolveStatusChain( } /** - * Cheap provider-availability probe for the read-only status path. - * - * v1.8.3 — when a status route runs in read-only mode (serve cache, never - * block on the LLM) it still has to tell the difference between "no - * provider — show the no-key fallback" and "provider configured but the - * assessment isn't warm yet — show preparing + enqueue a generation". This - * resolves the same chain `runStatusCompletion` would, but does NOT run a - * completion, so the navigation request never awaits an LLM round-trip. + * Whether any configured provider could serve text for `userId`: the one + * presence probe (`probeProviderPresence`). No client construction, no token + * refresh, no network. */ export async function hasUsableStatusProvider( userId: string, ): Promise { - return (await resolveStatusChain(userId)) !== null; -} - -/** - * v1.16.8 — true when a status generation for `userId` would be blocked - * by the server-managed consent gate (the same check `runStatusCompletion` - * applies before egress). The content-hash gate - * (`refreshUnchangedStatusInsight`) consults this BEFORE re-stamping a - * cached assessment as current: after a consent revocation the unchanged- - * data refresh must fall through to the generator, whose own gate then - * serves the no-key fallback instead of presenting old AI text as fresh. - * `false` when no provider is configured at all — that path already - * resolves to the fallback via `{ kind: "none" }`. - */ -export async function statusConsentBlocksGeneration( - userId: string, - surface: ConsentSurface, -): Promise { - const chain = await resolveStatusChain(userId); - if (chain === null) return false; - return ( - chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(userId, surface)) - ); + return probeProviderPresence(userId, "text"); } /** @@ -154,9 +144,22 @@ export async function runStatusCompletion( ): Promise { const { userId, cacheAction, systemPrompt, userPrompt } = args; + // The capability, re-checked at the wire. Every caller resolved it earlier + // (a worker before building its snapshot, a route before enqueueing), but + // switches, opt-outs and consent can change between the enqueue and here. + const capability = await aiCapabilityForRecord(userId, args.capability); + if (!capability.available) { + const reason = capability.reason ?? "check_failed"; + annotate({ + action: { name: "insights.status.capability_unavailable" }, + meta: { cacheAction, capability: args.capability, reason }, + }); + return { kind: "none", reason }; + } + const chain = await resolveStatusChain(userId); if (chain === null) { - return { kind: "none" }; + return { kind: "none", reason: "no_provider" }; } // v1.12.1 — consent gate before server-managed external egress. A chain @@ -168,13 +171,16 @@ export async function runStatusCompletion( // never trip this. if ( chainRequiresServerManagedConsent(chain) && - !(await hasActiveConsentForSurface(userId, args.consentSurface)) + !(await hasActiveConsentForSurface( + userId, + consentSurfaceFor(args.capability), + )) ) { annotate({ action: { name: "insights.status.consent_required" }, - meta: { cacheAction, surface: args.consentSurface }, + meta: { cacheAction, capability: args.capability }, }); - return { kind: "none" }; + return { kind: "none", reason: "consent_required" }; } // Honour the per-user response-timeout setting the operator dials in for a diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 0dc0316ff..8e8b8fc90 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -4,8 +4,7 @@ * Every `src/lib/insights/*-status.ts` generator carried byte-identical * copies of `round`, `normalizeSummaryText`, `normalizeLocale`, and (in * six of seven) `summarizeSeries`, plus an identical "parse the model's - * `{summary}` envelope" block and an identical `prisma.auditLog.create` - * persist block. This module is the single source of truth so a change + * `{summary}` envelope" block and an identical persist block. This module is the single source of truth so a change * to the rounding precision, the chart-token scrub, or the cache-row * shape lands once rather than seven times. * @@ -14,12 +13,12 @@ * reader's own language in an explicit output directive, so the reader's * locale must survive the whole pipeline rather than being collapsed to a * binary on the way in. The - * medication-compliance generator writes a richer cache `details` shape - * (it carries a per-medication array), so it shares `round`, - * `normalizeSummaryText`, `normalizeLocale`, and `parseSummaryFromContent` - * but keeps its own `auditLog.create`. + * medication-compliance generator stores per-medication lines next to its + * summary, so it shares `round`, `normalizeSummaryText`, `normalizeLocale`, + * and `parseSummaryFromContent` but writes its note through `writeStatusNote` + * directly. */ -import { prisma } from "@/lib/db"; +import { writeStatusNote } from "@/lib/insights/status-cache"; import { locales, type Locale } from "@/lib/i18n/config"; import { extractJsonObject } from "@/lib/ai/json-extract"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; @@ -306,62 +305,37 @@ export function parseSummaryFromContent(content: string): string { } /** - * Persist one status assessment cache row in the standard text-only - * shape (`{ dateKey, locale, text, providerType, model, tokensUsed }`). - * Returns the row's `createdAt` ISO string. The medication-compliance - * generator writes a richer `details` shape (per-medication array) and - * keeps its own `auditLog.create` call. + * Store one status note (`InsightStatusCache`, encrypted) for today and return + * when it was written. The medication-compliance generator also stores its + * per-medication lines and goes through `writeStatusNote` directly. */ export async function persistStatusInsight(args: { userId: string; cacheAction: string; todayKey: string; - locale: SupportedLocale; text: string; - providerType: string; - model: string; - tokensUsed: number | null; /** - * v1.16.8 — fingerprint of the data snapshot this assessment was - * generated from (see `snapshot-hash.ts`). The regeneration gate - * compares the fresh snapshot's hash against this and skips the - * provider call when nothing changed. + * Fingerprint of the data snapshot this note was written from (see + * `snapshot-hash.ts`). The regeneration gate compares the next snapshot's + * hash against it and skips the provider call when nothing changed. */ snapshotHash?: string; /** - * v1.18.11 (P6) — cheap fingerprint of the SALIENT INPUTS (per-type - * count + newest measuredAt) for slow-moving metrics. The input gate - * compares a freshly probed fingerprint against this BEFORE the heavy - * snapshot build, so a no-change day for weight/BMI skips the whole - * gather (not just the provider call). Absent on metrics that don't - * opt into the input gate. + * Cheap fingerprint of the SALIENT INPUTS (per-type count + newest + * measuredAt) for slow-moving metrics. The input gate compares a freshly + * probed fingerprint against it BEFORE the heavy snapshot build. */ inputHash?: string; }): Promise { - const created = await prisma.auditLog.create({ - data: { - userId: args.userId, - action: args.cacheAction, - details: JSON.stringify({ - dateKey: args.todayKey, - locale: args.locale, - text: args.text, - providerType: args.providerType, - model: args.model, - tokensUsed: args.tokensUsed, - statusKind: "generated", - retryable: false, - // Freshness is defined by the user's calendar day. Keeping the - // boundary in the payload makes expiry explicit without inventing a - // UTC timestamp for a day key that belongs to another timezone. - expiresAfterDateKey: args.todayKey, - ...(args.snapshotHash ? { snapshotHash: args.snapshotHash } : {}), - ...(args.inputHash ? { inputHash: args.inputHash } : {}), - }), - }, - select: { createdAt: true }, + const generatedAt = await writeStatusNote({ + userId: args.userId, + cacheAction: args.cacheAction, + todayKey: args.todayKey, + text: args.text, + snapshotHash: args.snapshotHash, + inputHash: args.inputHash, }); - return created.createdAt.toISOString(); + return generatedAt.toISOString(); } /** diff --git a/src/lib/insights/timeout-fallback.ts b/src/lib/insights/timeout-fallback.ts index 72c3956e4..7b7010e01 100644 --- a/src/lib/insights/timeout-fallback.ts +++ b/src/lib/insights/timeout-fallback.ts @@ -1,4 +1,4 @@ -import { prisma } from "@/lib/db"; +import { writeStatusNegativeWindow } from "@/lib/insights/status-cache"; import { annotate } from "@/lib/logging/context"; /** @@ -16,13 +16,12 @@ import { annotate } from "@/lib/logging/context"; * status route is now read-only: a cache miss enqueues an out-of-band * generation. If the provider stalls inside the worker, nothing stops the * next navigation from enqueuing again, and again — a stalled provider - * turns into a re-enqueue storm. So the timeout path writes a short-TTL - * negative stub (`{ timeout:true, model:"timeout-stub", retryAt }`). The - * read-only resolver honours it: while the stub is fresh it returns - * `preparing` WITHOUT re-enqueuing; once `retryAt` passes the stub is stale - * and the next visit re-attempts. The stub is NEVER served as assessment - * text — `readFreshStatusText` still rejects every timeout stub by marker, - * so a transient stall can't hide the real assessment for the day. + * turns into a re-enqueue storm. So the timeout path opens a short + * negative-cache window on the note (`retryAt`, `negativeReason`). The + * read-only resolver honours it: while the window is open it returns + * `preparing` WITHOUT re-enqueuing; once `retryAt` passes the next visit + * re-attempts. The window never replaces or hides a stored note, so a + * transient stall can't hide the real assessment for the day. */ /** @@ -53,20 +52,12 @@ export async function persistTimeoutNegativeStub(args: { reason: StatusFallbackReason; }): Promise { try { - await prisma.auditLog.create({ - data: { - userId: args.userId, - action: args.cacheAction, - details: JSON.stringify({ - dateKey: args.todayKey, - timeout: true, - model: "timeout-stub", - reason: args.reason, - retryAt: new Date( - Date.now() + TIMEOUT_NEGATIVE_CACHE_MS, - ).toISOString(), - }), - }, + await writeStatusNegativeWindow({ + userId: args.userId, + cacheAction: args.cacheAction, + todayKey: args.todayKey, + reason: args.reason, + retryAt: new Date(Date.now() + TIMEOUT_NEGATIVE_CACHE_MS), }); } catch { // Negative cache is best-effort — never throw out of the timeout path. diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 607e334bb..9b818a3f9 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -193,13 +193,13 @@ export async function prepareWeightStatusForUser( metric: "weight", locale, }); - // v1.16.13 — `consent-missing` serves the same no-key fallback (see - // bmi-status); no enqueue happens for it. - if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + // Unavailable for any reason serves the deterministic line and enqueues + // nothing. `hasProvider` is provider presence only. + if (outcome.kind === "unavailable") { return { phase: "served", result: { - hasProvider: false, + hasProvider: outcome.hasProvider, text: getNoKeyWeightStatusText(locale), cached: true, updatedAt: null, @@ -668,11 +668,7 @@ export async function prepareWeightStatusForUser( userId, cacheAction, todayKey, - locale, text: summary, - providerType: outcome.providerType, - model: outcome.model, - tokensUsed: outcome.tokensUsed, snapshotHash, // v1.18.11 (P6) — persist the input fingerprint so tomorrow's input // gate can skip the rebuild when nothing salient changed. diff --git a/src/lib/jobs/__tests__/workout-insight-generate.test.ts b/src/lib/jobs/__tests__/workout-insight-generate.test.ts index fd7c721ee..bd3798933 100644 --- a/src/lib/jobs/__tests__/workout-insight-generate.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-generate.test.ts @@ -36,7 +36,10 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, }, })); -vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: vi.fn() })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityForJob: vi.fn(), + aiCapabilityForRecord: vi.fn(), +})); vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn() })); vi.mock("@/lib/workouts/hr-series", () => ({ buildWorkoutHrSeries: vi.fn(), @@ -53,7 +56,7 @@ vi.mock("@/lib/measurements/pick-canonical-workout-rows", () => ({ import { runWorkoutInsightGenerate } from "../workout-insight-generate"; import { prisma } from "@/lib/db"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series"; import { runStatusCompletion } from "@/lib/insights/status-provider"; @@ -85,10 +88,11 @@ function workoutRow(over: Record = {}) { } function arrangeHappyPath() { - vi.mocked(resolveModuleMap).mockResolvedValue({ - workouts: true, - insights: true, - } as never); + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); vi.mocked(resolveUserTimezone).mockResolvedValue("Europe/Berlin"); vi.mocked(prisma.workout.findFirst).mockResolvedValue(workoutRow() as never); vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); @@ -155,35 +159,42 @@ describe("Activity Insight — the gate stack", () => { expect(write.create.paragraphEncrypted).toBeInstanceOf(Uint8Array); }); - it("gate 1 — refuses when the workouts module is off", async () => { - vi.mocked(resolveModuleMap).mockResolvedValue({ - workouts: false, - insights: true, - } as never); + it.each([ + "module_disabled", + "user_disabled", + "operator_disabled", + "no_provider", + "consent_required", + ] as const)( + "gate 1 — refuses when the workoutInsights capability is unavailable (%s), before any evidence is built", + async (reason) => { + vi.mocked(aiCapabilityForJob).mockResolvedValue({ + available: false, + reason, + onDeviceAllowed: false, + }); - const outcome = await runWorkoutInsightGenerate( - { userId: USER, workoutId: WORKOUT }, - NOW, - ); + const outcome = await runWorkoutInsightGenerate( + { userId: USER, workoutId: WORKOUT }, + NOW, + ); - expect(outcome).toEqual({ status: "skipped", reason: "module_off" }); - expect(runStatusCompletion).not.toHaveBeenCalled(); - expect(prisma.workoutInsight.upsert).not.toHaveBeenCalled(); - }); + expect(aiCapabilityForJob).toHaveBeenCalledWith(USER, "workoutInsights"); + expect(outcome).toEqual({ status: "skipped", reason }); + expect(prisma.workout.findFirst).not.toHaveBeenCalled(); + expect(buildWorkoutHrSeries).not.toHaveBeenCalled(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(prisma.workoutInsight.upsert).not.toHaveBeenCalled(); + }, + ); - it("gate 1 — refuses when the insights module is off", async () => { - vi.mocked(resolveModuleMap).mockResolvedValue({ - workouts: true, - insights: false, - } as never); + it("routes the provider call through the workoutInsights capability", async () => { + await runWorkoutInsightGenerate({ userId: USER, workoutId: WORKOUT }, NOW); - const outcome = await runWorkoutInsightGenerate( - { userId: USER, workoutId: WORKOUT }, - NOW, - ); - - expect(outcome).toEqual({ status: "skipped", reason: "module_off" }); - expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(vi.mocked(runStatusCompletion).mock.calls[0][0]).toMatchObject({ + userId: USER, + capability: "workoutInsights", + }); }); it("gate 2 — refuses a session under the ten-minute floor", async () => { @@ -335,7 +346,10 @@ describe("Activity Insight — the gate stack", () => { }); it("gate 5 — a provider-less install writes no row", async () => { - vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" }); + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "none", + reason: "no_provider", + }); const outcome = await runWorkoutInsightGenerate( { userId: USER, workoutId: WORKOUT }, @@ -499,7 +513,7 @@ describe("Activity Insight — durable generation ownership", () => { }); it.each([ - { kind: "none" as const }, + { kind: "none" as const, reason: "no_provider" as const }, { kind: "timeout" as const }, { kind: "error" as const }, ])( diff --git a/src/lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts b/src/lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts index 3d29e50d3..cf48bc32d 100644 --- a/src/lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts @@ -23,6 +23,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap: vi.fn() })); +// The spine's provider-free prefilter (`aiWorkNotRuledOut`) runs for real; only +// its operator-switch read is an edge. +vi.mock("@/lib/feature-flags", () => ({ loadAssistantSwitches: vi.fn() })); vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn() })); vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserDashboardSnapshot: vi.fn(), @@ -47,6 +50,7 @@ import { runDataArrival } from "../data-arrival"; import { enqueueWorkoutInsight } from "@/lib/jobs/workout-insight-generate-shared"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { resolveModuleMap } from "@/lib/modules/gate"; +import { loadAssistantSwitches } from "@/lib/feature-flags"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import type { DataArrival } from "@/lib/arrivals/types"; @@ -80,6 +84,13 @@ beforeEach(() => { workouts: true, insights: true, } as never); + vi.mocked(loadAssistantSwitches).mockResolvedValue({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + documentAi: true, + }); vi.mocked(resolveUserTimezone).mockResolvedValue(TZ); }); diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index 67966b14b..cc1b29168 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -52,7 +52,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; -import { resolveModuleMap } from "@/lib/modules/gate"; +import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; import { userDayKey } from "@/lib/tz/format"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { startOfLocalDayInTz } from "@/lib/tz/local-day"; @@ -215,13 +215,19 @@ export async function runWorkoutInsightGenerate( if (!userId || !workoutId) return { status: "skipped", reason: "bad_payload" }; - // ── Gate 1: modules ────────────────────────────────────────────────────── - // Both, not either. `workouts` is the domain the surface lives in and - // `insights` is the AI surface family it belongs to; a user who turned either - // off has said no to this card. - const modules = await resolveModuleMap(userId); - if (modules.workouts === false || modules.insights === false) { - return { status: "skipped", reason: "module_off" }; + // ── Gate 1: the `workoutInsights` capability ───────────────────────────── + // The operator's switches (the master included, which this job never read + // before), both owning modules (`workouts` is the domain, `insights` the + // person's AI analysis switch), provider presence and consent, in one + // answer, before any evidence is built. The chokepoint re-checks at the wire. + const capability = await aiCapabilityForJob(userId, "workoutInsights"); + if (!capability.available) { + const reason = capability.reason ?? "check_failed"; + annotate({ + action: { name: "workouts.insight.skipped" }, + meta: { workoutId, reason }, + }); + return { status: "skipped", reason }; } const row = await prisma.workout.findFirst({ @@ -429,7 +435,7 @@ export async function runWorkoutInsightGenerate( outcome = await runStatusCompletion({ userId, cacheAction: "insights.workout-insight", - consentSurface: "insights", + capability: "workoutInsights", systemPrompt: getWorkoutInsightSystemPrompt(prepared.locale), userPrompt: getWorkoutInsightUserPrompt( JSON.stringify(prepared.evidence), diff --git a/src/lib/workouts/insight-gates.ts b/src/lib/workouts/insight-gates.ts index f02beee75..a0246f3c1 100644 --- a/src/lib/workouts/insight-gates.ts +++ b/src/lib/workouts/insight-gates.ts @@ -5,7 +5,8 @@ * in this order for a reason — each one is cheaper than the one after it, so * the common refusals cost nothing: * - * 1. modules — `workouts` AND `insights` both on (one cached map lookup) + * 1. capability — `workoutInsights` (both owning modules, the operator + * switches, provider presence and consent in one answer) * 2. duration — at least `MIN_DURATION_SEC` (a field already in hand) * 3. daily cap — at most `MAX_INSIGHTS_PER_DAY` generated today (one COUNT) * 4. input hash — the evidence is unchanged since the last paragraph (one diff --git a/tests/integration/insights-status-real-text.test.ts b/tests/integration/insights-status-real-text.test.ts index 3c21ab49e..248965294 100644 --- a/tests/integration/insights-status-real-text.test.ts +++ b/tests/integration/insights-status-real-text.test.ts @@ -8,9 +8,9 @@ * the generic no-key fallback). This is the regression the * sticky-stub bug used to break. * - * 2. A timeout-stub cache row keyed to today is NOT served — the - * generator skips it and re-generates, proving the stub no longer - * pins the fallback for the day. + * 2. A negative-cache window opened by an earlier stall never stands in + * for a note: with no note stored for today, the generator regenerates + * and stores the real text, and writing it closes the window. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -24,7 +24,10 @@ vi.mock("@/lib/db-compat", () => ({ // provider to walk. `runRawCompletionWithFallback` runs for real on top // of this — exercising the same plumbing the live status path uses. const KNOWN_TEXT = "Your weight has trended down steadily over the past month."; -vi.mock("@/lib/ai/provider", () => ({ +vi.mock("@/lib/ai/provider", async (importOriginal) => ({ + // The presence probe the capability reads stays real; only the chain the + // generation walks is replaced. + ...(await importOriginal()), resolveProviderChain: vi.fn().mockResolvedValue([ { providerType: "anthropic", @@ -41,7 +44,7 @@ vi.mock("@/lib/ai/provider", () => ({ resolveProvider: vi.fn().mockResolvedValue({ type: "none" }), })); -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; beforeEach(async () => { await truncateAllTables(getPrismaClient()); @@ -60,6 +63,9 @@ async function seedRichWeightUser(username: string) { role: "USER", heightCm: 180, dateOfBirth: new Date("1985-01-01"), + // Presence of a provider for the capability; never decrypted here. + aiProviderChain: [{ providerType: "anthropic", enabled: true }], + aiAnthropicKeyEncrypted: "key-present", }, }); const now = Date.now(); @@ -90,39 +96,40 @@ describe("status path delivers real provider text", () => { expect(result.cached).toBe(false); expect(result.text).toBe(KNOWN_TEXT); - // The real assessment was persisted (not a stub). + // The real assessment was stored, encrypted, as the weight note. const prisma = getPrismaClient(); - const cached = await prisma.auditLog.findFirst({ - where: { userId: user.id, action: "insights.weight-status.en" }, - orderBy: { createdAt: "desc" }, - select: { details: true }, + const stored = await prisma.insightStatusCache.findUniqueOrThrow({ + where: { + userId_metric_locale: { + userId: user.id, + metric: "weight", + locale: "en", + }, + }, }); - const parsed = JSON.parse(cached?.details ?? "{}") as { - text: string; - model: string; - timeout?: boolean; - }; - expect(parsed.text).toBe(KNOWN_TEXT); - expect(parsed.model).toBe("test-model"); - expect(parsed.timeout).toBeUndefined(); + expect(decryptFromBytes(stored.textEncrypted!)).toBe(KNOWN_TEXT); + expect(stored.retryAt).toBeNull(); + // Nothing about the note lands in the audit log any more. + expect( + await prisma.auditLog.count({ + where: { userId: user.id, action: { contains: "-status." } }, + }), + ).toBe(0); }); - it("skips a timeout-stub cache row and regenerates the real text", async () => { + it("regenerates past a negative-cache window and closes it", async () => { const user = await seedRichWeightUser("status-stub-skip-user"); const prisma = getPrismaClient(); - // Pre-seed a stub row keyed to today — the bug used to serve this. - await prisma.auditLog.create({ + // An earlier stall opened a window, with no note stored. + await prisma.insightStatusCache.create({ data: { userId: user.id, - action: "insights.weight-status.en", - details: JSON.stringify({ - dateKey: toBerlinDayKey(new Date()), - locale: "en", - text: "Generic fallback advice that must not be served.", - model: "timeout-stub", - timeout: true, - }), + metric: "weight", + locale: "en", + dateKey: "2026-01-01", + retryAt: new Date(Date.now() + 60_000), + negativeReason: "timeout", }, }); @@ -133,8 +140,18 @@ describe("status path delivers real provider text", () => { locale: "en", }); - // The stub is skipped; the real provider text is returned + cached. expect(result.text).toBe(KNOWN_TEXT); expect(result.cached).toBe(false); + const stored = await prisma.insightStatusCache.findUniqueOrThrow({ + where: { + userId_metric_locale: { + userId: user.id, + metric: "weight", + locale: "en", + }, + }, + }); + expect(stored.retryAt).toBeNull(); + expect(stored.negativeReason).toBeNull(); }); });