mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'feat/ai-jobs-stored-text' into release/v1.39.0
This commit is contained in:
commit
c9534b343e
114 files changed
+6002
-2436
No files matched your search
+31
-9
@@ -12774,6 +12774,7 @@ paths:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
description: Narrow to one consent kind. Omit for the full keyspace.
|
||||
responses:
|
||||
"200":
|
||||
@@ -12785,8 +12786,9 @@ paths:
|
||||
- $ref: "#/components/schemas/ConsentLatestForKindEnvelope"
|
||||
- $ref: "#/components/schemas/ConsentLatestByKindEnvelope"
|
||||
"400": &a22
|
||||
description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope;
|
||||
nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422."
|
||||
description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`.
|
||||
Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the
|
||||
API answers 422."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -12809,9 +12811,18 @@ paths:
|
||||
each revocation is audit-logged.
|
||||
|
||||
|
||||
Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH
|
||||
/api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls
|
||||
should show them as the two separate things they are.
|
||||
Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document
|
||||
reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on.
|
||||
Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH
|
||||
/api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things
|
||||
they are.
|
||||
|
||||
|
||||
When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same
|
||||
transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the
|
||||
cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations,
|
||||
facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts
|
||||
only (`consent.ai.purge`).
|
||||
parameters:
|
||||
- in: query
|
||||
name: kind
|
||||
@@ -12822,6 +12833,7 @@ paths:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
description: Revoke one kind. Omit to revoke the latest active receipt across every kind.
|
||||
responses:
|
||||
"200":
|
||||
@@ -23992,8 +24004,8 @@ components:
|
||||
type: boolean
|
||||
required:
|
||||
- documentsAutoAiRead
|
||||
description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full`
|
||||
consent receipt and schedules a catch-up over the existing vault.
|
||||
description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an
|
||||
`ai_extraction` consent receipt and schedules a catch-up over the existing vault.
|
||||
InjectionSitePrefsPatchRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -26151,6 +26163,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
artefact:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -58183,6 +58196,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
signedAt:
|
||||
type: string
|
||||
revokedAt:
|
||||
@@ -58279,6 +58293,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
receipt:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/SerialisedConsentReceipt"
|
||||
@@ -58301,6 +58316,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
signedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -58357,13 +58373,18 @@ components:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/SerialisedConsentReceipt"
|
||||
- type: "null"
|
||||
ai_extraction:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/SerialisedConsentReceipt"
|
||||
- type: "null"
|
||||
required:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
additionalProperties: false
|
||||
description: "The latest active receipt for every kind. All three keys are always present: a null value means the kind
|
||||
is not granted, so a client never has to read absence as ambiguity."
|
||||
description: "The latest active receipt for every kind. Every key is always present: a null value means the kind is not
|
||||
granted, so a client never has to read absence as ambiguity."
|
||||
ConsentRevokeForKindEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -58412,6 +58433,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
receipt:
|
||||
$ref: "#/components/schemas/SerialisedConsentReceipt"
|
||||
required:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Status notes move out of the audit log.
|
||||
--
|
||||
-- The per-metric status notes used to be appended to `audit_logs` as plaintext
|
||||
-- JSON, one row per metric per day, next to the security trail. They now live
|
||||
-- in their own table, encrypted at rest, one row per (user, metric, locale),
|
||||
-- upserted in place.
|
||||
--
|
||||
-- 1. The new table.
|
||||
-- CreateTable
|
||||
CREATE TABLE "insight_status_caches" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"metric" TEXT NOT NULL,
|
||||
"locale" TEXT NOT NULL,
|
||||
"text_encrypted" BYTEA,
|
||||
"items_encrypted" BYTEA,
|
||||
"input_hash" TEXT,
|
||||
"snapshot_hash" TEXT,
|
||||
"date_key" TEXT NOT NULL,
|
||||
"generated_at" TIMESTAMP(3),
|
||||
"retry_at" TIMESTAMP(3),
|
||||
"negative_reason" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "insight_status_caches_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "insight_status_caches_user_id_metric_locale_key" ON "insight_status_caches"("user_id", "metric", "locale");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "insight_status_caches" ADD CONSTRAINT "insight_status_caches_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- 2. The old rows go. DESTRUCTIVE by design: the notes are a regenerable
|
||||
-- cache, nothing reads them from `audit_logs` any more, and leaving them
|
||||
-- would keep plaintext health text in a table documented as holding none.
|
||||
-- Nothing is copied across, because the old rows are plaintext and a
|
||||
-- migration cannot encrypt; the first nightly run after the upgrade writes
|
||||
-- every note again. Take a database backup before applying, as for 0339.
|
||||
-- Matches exactly the cache keys `insights.<scope>-status.<locale>` (the
|
||||
-- seven specialised scopes and the `metric:` / `derived-score:` /
|
||||
-- `biomarker:` generic ones); no security-trail action has this shape.
|
||||
DELETE FROM "audit_logs" WHERE "action" LIKE 'insights.%-status.%';
|
||||
@@ -860,6 +860,9 @@ model User {
|
||||
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
|
||||
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
|
||||
insightNarratives InsightNarrative[]
|
||||
// v1.39.0 — the per-metric status notes, one encrypted row per
|
||||
// (metric, locale). A regenerable cache; it used to live in `audit_logs`.
|
||||
insightStatusCaches InsightStatusCache[]
|
||||
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
|
||||
// row per (kind, local day); the unique index IS the once-per-kind-per-day
|
||||
// claim every reaction surface reads. Retained 14 days.
|
||||
@@ -6722,6 +6725,55 @@ model InsightNarrative {
|
||||
@@map("insight_narratives")
|
||||
}
|
||||
|
||||
// v1.39.0 — the per-metric status notes ("Status notes"): what a model wrote
|
||||
// about one metric for one person in one language, and the fingerprints that
|
||||
// let the next run skip the model when nothing changed.
|
||||
//
|
||||
// One row per (user, metric, locale), upserted in place. Until v1.39.0 these
|
||||
// notes were appended to `audit_logs` as plaintext JSON, one row per metric per
|
||||
// day, mixed with the security trail; migration 0344 dropped those rows, so the
|
||||
// first night after the upgrade regenerates every note once.
|
||||
//
|
||||
// A regenerable cache, not a record: excluded from backups, deleted by "Delete
|
||||
// All Data", purged when the person withdraws AI consent, and never served
|
||||
// while the `statusText` capability is unavailable.
|
||||
model InsightStatusCache {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// The status scope: `weight`, `blood-pressure`, `general`, …, or a prefixed
|
||||
/// generic scope (`metric:<ID>`, `derived-score:<ID>`, `biomarker:<id>`).
|
||||
metric String
|
||||
/// The UI locale the note was written in.
|
||||
locale String
|
||||
/// AES-256-GCM ciphertext of the note. Null while the row only carries a
|
||||
/// negative-cache window (a generation that stalled before any note existed).
|
||||
textEncrypted Bytes? @map("text_encrypted")
|
||||
/// AES-256-GCM ciphertext of per-item notes as JSON, for a card that carries
|
||||
/// them (medication compliance: one line per medication). Null otherwise.
|
||||
itemsEncrypted Bytes? @map("items_encrypted")
|
||||
/// Cheap fingerprint of the salient inputs (count + newest reading).
|
||||
inputHash String? @map("input_hash")
|
||||
/// Fingerprint of the full data snapshot the note was written from.
|
||||
snapshotHash String? @map("snapshot_hash")
|
||||
/// The person's local YYYY-MM-DD the note counts as current for.
|
||||
dateKey String @map("date_key")
|
||||
/// When the note was written, or last re-dated by an unchanged-data run.
|
||||
generatedAt DateTime? @map("generated_at")
|
||||
/// Negative cache: while in the future, a read does not enqueue another
|
||||
/// generation. Cleared when a note is written.
|
||||
retryAt DateTime? @map("retry_at")
|
||||
/// Why the last generation served the deterministic line instead of a note:
|
||||
/// `timeout` | `error` | `screened`. Set together with `retryAt`.
|
||||
negativeReason String? @map("negative_reason")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, metric, locale])
|
||||
@@map("insight_status_caches")
|
||||
}
|
||||
|
||||
// ─── Data-arrival spine (v1.31.0) ─────────────────────────────
|
||||
//
|
||||
// One row per (user, arrival kind, local day). The row is written by the
|
||||
|
||||
@@ -454,6 +454,25 @@ async function main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.39.0 InsightStatusCache."textEncrypted" / "itemsEncrypted" ─────
|
||||
// The per-metric status notes. Both nullable (a row may carry only a
|
||||
// negative-cache window; only one card stores per-item lines), and
|
||||
// `rotateBytesColumn` skips a NULL payload.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"InsightStatusCache",
|
||||
"textEncrypted",
|
||||
prisma.insightStatusCache,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"InsightStatusCache",
|
||||
"itemsEncrypted",
|
||||
prisma.insightStatusCache,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.31.0 ArrivalReaction."lineEncrypted" (Bytes, nullable) ─────
|
||||
// The data-arrival spine's generated reaction line. Nullable by design — a
|
||||
// provider-less install writes markers with no line at all — and
|
||||
|
||||
+30
-24
@@ -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.<scope>-status.<locale>`
|
||||
// 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ const CONSENT_HELPERS = [
|
||||
"assertConsentForChain",
|
||||
"chainRequiresServerManagedConsent",
|
||||
"hasActiveConsentForSurface",
|
||||
// Document-class, pick-shaped.
|
||||
"assertDocumentEgressConsent",
|
||||
// Document-class, pick-shaped (the receipt itself is read by the capability
|
||||
// egress re-check below).
|
||||
"isExternalDocumentEgress",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Structural guard on the places a model is actually called.
|
||||
*
|
||||
* A capability is only as good as the last check before the wire. A job
|
||||
* enqueued before an operator flipped a switch, or before the person withdrew
|
||||
* consent or turned AI analysis off, must still stop there. So every file that
|
||||
* hands a prompt to a provider (`.generateCompletion(`, the chain runners) is
|
||||
* named here with the reason its call is admitted, and the ones that ARE a
|
||||
* chokepoint must re-check a capability in the same file.
|
||||
*
|
||||
* Mirrors `ai-consent-enforcement-guard.test.ts` in shape and in its limits: it
|
||||
* proves a reference exists, not that it sits on every branch or before the
|
||||
* call. The integration suite with a provider spy
|
||||
* (`tests/integration/ai-optional-jobs.test.ts`) is what proves behaviour.
|
||||
* A future egress helper under a new name would slip the matcher; a reviewer
|
||||
* adding one should add it here.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** A provider call: a client's completion (plain or streamed), or a chain runner. */
|
||||
const EGRESS_CALL =
|
||||
/\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/;
|
||||
|
||||
/**
|
||||
* A capability read in the same file: one of the gates, or the wire re-check
|
||||
* (`aiEgressRefusal` / `assertAiEgress`), which also answers the consent a
|
||||
* picked provider needs.
|
||||
*/
|
||||
const CAPABILITY_CHECK =
|
||||
/\b(?:aiCapabilityForJob|aiCapabilityForRecord|requireAiCapability|getAiCapability|aiEgressRefusal|assertAiEgress)\s*\(/;
|
||||
|
||||
/** Comments name the helpers too; only code counts. */
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function code(rel: string): string {
|
||||
return stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
}
|
||||
|
||||
interface EgressSite {
|
||||
/** True when this file is a chokepoint and must re-check a capability. */
|
||||
recheck: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file that calls a provider, and why the call is admitted.
|
||||
*/
|
||||
const EGRESS_SITES: Record<string, EgressSite> = {
|
||||
// ── Chokepoints: re-check at the wire ──────────────────────────────────
|
||||
"lib/insights/status-provider.ts": {
|
||||
recheck: true,
|
||||
reason:
|
||||
"runStatusCompletion: status notes, narratives, workout paragraphs, derived and biomarker assessments, Coach memory. Takes the capability as a required argument and re-checks it before the chain is resolved.",
|
||||
},
|
||||
"lib/jobs/reaction-line.ts": {
|
||||
recheck: true,
|
||||
reason:
|
||||
"Reaction lines call the first chain entry directly; the worker resolves `reactionLines` before the digest and re-checks the chain at the wire (`aiEgressRefusal`).",
|
||||
},
|
||||
"lib/jobs/coach-nudge-ai.ts": {
|
||||
recheck: true,
|
||||
reason:
|
||||
"AI-composed nudges call the first chain entry directly; the composer re-checks `coach` before the chain.",
|
||||
},
|
||||
|
||||
// ── Admitted by the one caller that reaches them ───────────────────────
|
||||
"lib/insights/briefing-provider.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"Reached only through generateComprehensiveInsight, which re-checks `briefing` before the chain is resolved.",
|
||||
},
|
||||
"lib/ai/coach/tools/loop.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"The Coach tool loop runs inside the chat stream, which the chat route gates on `coach`.",
|
||||
},
|
||||
"lib/ai/coach/self-context-questions.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"About-me follow-up questions; reached from the about-me route, which gates `aboutMeQuestions`.",
|
||||
},
|
||||
"lib/documents/assist.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"Document suggest/chat helpers; reached from the document routes and jobs, which resolve `documentAi` before a provider is picked.",
|
||||
},
|
||||
"lib/documents/describe.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"Document summary; reached from the summary route and the summary job, which resolve `documentAi` first.",
|
||||
},
|
||||
"lib/documents/extract.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"Document extraction/indexing; reached from the document routes and the index jobs, which resolve `documentAi` first.",
|
||||
},
|
||||
"lib/labs/ocr-extract.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"Lab report scan; reached from the labs OCR route, which gates `labsOcr`.",
|
||||
},
|
||||
"app/api/medications/extract/route.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"Medication text extraction; the route is the chokepoint for `medicationExtract`.",
|
||||
},
|
||||
"app/api/ai/test/route.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"The connection test a person runs on a provider they are configuring; it sends a fixed probe prompt, no health data.",
|
||||
},
|
||||
|
||||
// ── The machinery itself ────────────────────────────────────────────────
|
||||
"lib/ai/provider-runner.ts": {
|
||||
recheck: false,
|
||||
reason: "The chain runners; every caller above is what admits a run.",
|
||||
},
|
||||
"lib/ai/openai-client.ts": {
|
||||
recheck: false,
|
||||
reason: "A wire client delegating to its own completion method.",
|
||||
},
|
||||
"lib/ai/local-client.ts": {
|
||||
recheck: false,
|
||||
reason: "A wire client delegating to its own completion method.",
|
||||
},
|
||||
"lib/ai/coach/eval/judge.ts": {
|
||||
recheck: false,
|
||||
reason: "The offline evaluation harness; never on a request or job path.",
|
||||
},
|
||||
};
|
||||
|
||||
describe("AI egress call sites", () => {
|
||||
const found = sourceFiles().filter((rel) => EGRESS_CALL.test(code(rel)));
|
||||
|
||||
it("finds provider calls at all (an empty matcher is a failure, not a pass)", () => {
|
||||
expect(found.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("every provider call site is named, with a reason", () => {
|
||||
const unlisted = found.filter((rel) => !(rel in EGRESS_SITES));
|
||||
expect(
|
||||
unlisted,
|
||||
"A new file calls a provider. Add it to EGRESS_SITES with the reason its call is admitted, and re-check a capability in it if it is a chokepoint.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the list carries no file that no longer calls a provider", () => {
|
||||
const stale = Object.keys(EGRESS_SITES).filter(
|
||||
(rel) => !found.includes(rel),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("every chokepoint re-checks a capability in the same file", () => {
|
||||
const missing = Object.entries(EGRESS_SITES)
|
||||
.filter(([, site]) => site.recheck)
|
||||
.filter(([rel]) => !CAPABILITY_CHECK.test(code(rel)))
|
||||
.map(([rel]) => rel);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the status chokepoint's capability argument", () => {
|
||||
it("is required by the type (the @ts-expect-error below fails typecheck if it is not)", () => {
|
||||
type Args = Parameters<typeof runStatusCompletion>[0];
|
||||
// @ts-expect-error — `capability` is required: a caller that omits it does not compile.
|
||||
const withoutCapability: Args = {
|
||||
userId: "u",
|
||||
cacheAction: "insights.weight-status.en",
|
||||
systemPrompt: "s",
|
||||
userPrompt: "p",
|
||||
};
|
||||
const withCapability: Args = {
|
||||
...withoutCapability,
|
||||
capability: "statusText",
|
||||
};
|
||||
expect(withCapability.capability).toBe("statusText");
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
|
||||
*/
|
||||
const EXEMPT_TEMPLATES: Record<string, string> = {
|
||||
"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}":
|
||||
|
||||
@@ -37,6 +37,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types";
|
||||
import { AI_EXTRACTION_CONSENT_KIND } from "@/lib/consent/receipts";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
@@ -57,7 +58,7 @@ type DocumentsAutoAiReadResponse = {
|
||||
* master `ai_full`. The capability table lists it (with `ai_full`) as what
|
||||
* satisfies the document consent rule.
|
||||
*/
|
||||
const AUTO_READ_CONSENT_KIND = "ai_extraction";
|
||||
const AUTO_READ_CONSENT_KIND = AI_EXTRACTION_CONSENT_KIND;
|
||||
|
||||
/**
|
||||
* Mint the extraction receipt unless one that satisfies document reads is
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
|
||||
// mock runs the callback against the same proxy.
|
||||
// v1.16.16 — `createReceipt` and the withdrawal run inside a transaction;
|
||||
// the mock runs the callback against the same proxy. The withdrawal also
|
||||
// purges the regenerable AI text, so the proxy carries those delegates too.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
const prisma = {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
insightStatusCache: { deleteMany: vi.fn() },
|
||||
user: { updateMany: vi.fn() },
|
||||
insightNarrative: { deleteMany: vi.fn() },
|
||||
arrivalReaction: { updateMany: vi.fn() },
|
||||
workoutInsight: { deleteMany: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
prisma.$transaction.mockImplementation((fn: TxFn) => fn(prisma));
|
||||
return { prisma };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -78,9 +84,21 @@ beforeEach(() => {
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
$transaction.mockImplementation((fn: TxFn) => fn(prisma));
|
||||
// Default: another receipt still covers the analysis, so no purge runs.
|
||||
// The purge cases below set this to 0.
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
|
||||
for (const deleteMany of [
|
||||
prisma.insightStatusCache.deleteMany,
|
||||
prisma.insightNarrative.deleteMany,
|
||||
prisma.workoutInsight.deleteMany,
|
||||
]) {
|
||||
vi.mocked(deleteMany).mockResolvedValue({ count: 0 } as never);
|
||||
}
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
vi.mocked(prisma.arrivalReaction.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/consent/ai/latest", () => {
|
||||
@@ -278,14 +296,15 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
|
||||
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
|
||||
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
|
||||
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
|
||||
// ai_insights_only has none (count 0, no re-read).
|
||||
// The withdrawal runs per kind in `consentKindEnum.options` order:
|
||||
// ai_full → ai_insights_only → ai_coach → ai_extraction. The
|
||||
// `updateMany` count gates the follow-up re-read. ai_full + ai_coach
|
||||
// revoke a row; the others have none (count 0, no re-read).
|
||||
vi.mocked(prisma.consentReceipt.updateMany)
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
|
||||
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
|
||||
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_coach
|
||||
.mockResolvedValueOnce({ count: 0 } as never); // ai_extraction
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-full",
|
||||
@@ -323,6 +342,80 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/consent/ai/latest — purge of regenerable AI text", () => {
|
||||
const revokedRow = {
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_insights_only",
|
||||
artefact: "PDF",
|
||||
signedAt: new Date("2026-05-18T10:00:00.000Z"),
|
||||
revokedAt: new Date("2026-05-18T11:00:00.000Z"),
|
||||
createdAt: new Date("2026-05-18T10:00:01.000Z"),
|
||||
};
|
||||
|
||||
it("deletes the regenerable text and writes a counts-only purge audit row when nothing covers the analysis any more", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
revokedRow as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.insightStatusCache.deleteMany).mockResolvedValue({
|
||||
count: 4,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.insightStatusCache.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
});
|
||||
expect(prisma.workoutInsight.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith("consent.ai.purge", {
|
||||
userId: "user-1",
|
||||
details: {
|
||||
statusNotes: 4,
|
||||
briefing: 0,
|
||||
narratives: 0,
|
||||
reactionLines: 0,
|
||||
workoutParagraphs: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the text while another receipt still covers the analysis", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
revokedRow as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.insightStatusCache.deleteMany).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalledWith(
|
||||
"consent.ai.purge",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("append-only invariant", () => {
|
||||
/**
|
||||
* v1.4.40 SB-10 — the receipts table is the legal audit trail.
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
* DELETE /api/consent/ai/latest
|
||||
* Master toggle — revokes the latest active receipt across every
|
||||
* consent kind. Returns the list of revoked rows.
|
||||
*
|
||||
* Either DELETE also deletes the regenerable model-written text (status
|
||||
* notes, the cached briefing, model-written narratives, reaction lines,
|
||||
* workout paragraphs) when it leaves no receipt covering the analysis, in the
|
||||
* same transaction as the revoke. Coach memory and document summaries stay.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -28,8 +33,8 @@ import {
|
||||
import {
|
||||
latestActiveReceipt,
|
||||
latestActiveReceiptsByKind,
|
||||
revokeLatest,
|
||||
} from "@/lib/consent/receipts";
|
||||
import { withdrawConsent } from "@/lib/consent/withdrawal";
|
||||
import { serialiseReceipt } from "../route";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -81,6 +86,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? serialiseReceipt(byKind.ai_insights_only)
|
||||
: null,
|
||||
ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null,
|
||||
ai_extraction: byKind.ai_extraction
|
||||
? serialiseReceipt(byKind.ai_extraction)
|
||||
: null,
|
||||
};
|
||||
|
||||
annotate({
|
||||
@@ -119,53 +127,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { kind } = parsed.data;
|
||||
|
||||
if (kind) {
|
||||
const revoked = await revokeLatest(user.id, kind);
|
||||
if (!revoked) {
|
||||
// Idempotent: "no active receipt to revoke" is success-shaped.
|
||||
// The iOS toggle hammers this on every flip; returning 404
|
||||
// would surface as a spurious error in the client.
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: { kind, revoked: false },
|
||||
});
|
||||
return apiSuccess({ kind, receipt: null });
|
||||
}
|
||||
// One kind, or every kind for the master "turn AI off" toggle. The revoke
|
||||
// and the purge of the regenerable text the consent covered commit
|
||||
// together (`withdrawConsent`).
|
||||
const kinds = kind ? [kind] : consentKindEnum.options;
|
||||
const { revoked, purged } = await withdrawConsent(user.id, kinds);
|
||||
|
||||
for (const { kind: k, receipt } of revoked) {
|
||||
auditLog("consent.ai.revoke", {
|
||||
userId: user.id,
|
||||
details: { kind, receiptId: revoked.id },
|
||||
details: { kind: k, receiptId: receipt.id },
|
||||
}).catch(() => {});
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: { kind, receiptId: revoked.id },
|
||||
});
|
||||
return apiSuccess({ kind, receipt: serialiseReceipt(revoked) });
|
||||
}
|
||||
|
||||
// No kind specified — master "AI deaktivieren" toggle. Revoke the
|
||||
// latest active row for every kind that currently has one.
|
||||
const revokedRows: Array<{
|
||||
kind: ConsentKind;
|
||||
receipt: ReturnType<typeof serialiseReceipt>;
|
||||
}> = [];
|
||||
for (const k of consentKindEnum.options) {
|
||||
const r = await revokeLatest(user.id, k);
|
||||
if (r) {
|
||||
revokedRows.push({ kind: k, receipt: serialiseReceipt(r) });
|
||||
auditLog("consent.ai.revoke", {
|
||||
userId: user.id,
|
||||
details: { kind: k, receiptId: r.id },
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (purged) {
|
||||
// Counts only, never content.
|
||||
auditLog("consent.ai.purge", {
|
||||
userId: user.id,
|
||||
details: { ...purged },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: {
|
||||
kinds_revoked: revokedRows.map((r) => r.kind),
|
||||
count: revokedRows.length,
|
||||
kinds_revoked: revoked.map((r) => r.kind),
|
||||
count: revoked.length,
|
||||
purged: purged !== null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: revokedRows });
|
||||
if (kind) {
|
||||
// Idempotent: "no active receipt to revoke" is success-shaped. The iOS
|
||||
// toggle hammers this on every flip; returning 404 would surface as a
|
||||
// spurious error in the client.
|
||||
const receipt = revoked[0]?.receipt ?? null;
|
||||
return apiSuccess({
|
||||
kind,
|
||||
receipt: receipt ? serialiseReceipt(receipt) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
revoked: revoked.map((r) => ({
|
||||
kind: r.kind,
|
||||
receipt: serialiseReceipt(r.receipt),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -3,25 +3,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
vi.mock("@/lib/consent/receipts", () => ({
|
||||
latestActiveReceipt: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/document-settings", () => ({
|
||||
documentAutoReadEnabled: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
import {
|
||||
ConsentRequiredError,
|
||||
assertConsentForChain,
|
||||
assertDocumentEgressConsent,
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
isExternalDocumentEgress,
|
||||
} from "../consent-guard";
|
||||
import { latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
|
||||
import type { ProviderChainResolved } from "../provider-runner";
|
||||
import type { ConsentKind } from "@/lib/validations/consent";
|
||||
|
||||
const mockedLatest = vi.mocked(latestActiveReceipt);
|
||||
const mockedAutoRead = vi.mocked(documentAutoReadEnabled);
|
||||
|
||||
/** Minimal chain entry — the gate only inspects `providerType`. */
|
||||
function entry(providerType: string): ProviderChainResolved {
|
||||
@@ -203,103 +197,20 @@ describe("isExternalDocumentEgress", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertDocumentEgressConsent", () => {
|
||||
beforeEach(() => {
|
||||
mockedLatest.mockReset();
|
||||
mockedAutoRead.mockReset();
|
||||
// Default: the auto-read toggle is OFF (the shipped privacy posture).
|
||||
mockedAutoRead.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("never reads consent and never throws for a LOCAL document pick", async () => {
|
||||
grant([]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "local",
|
||||
surface: "insights",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockedLatest).not.toHaveBeenCalled();
|
||||
// A local pick short-circuits before the toggle is even consulted.
|
||||
expect(mockedAutoRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The live gap the governance fix closes: codex was ungated for documents.
|
||||
it("REQUIRES a receipt to send a document to codex (the closed gap)", async () => {
|
||||
grant([]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "codex",
|
||||
surface: "insights",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
});
|
||||
|
||||
it("requires a receipt for BYOK document egress too (openai / anthropic)", async () => {
|
||||
grant([]);
|
||||
for (const p of ["openai", "anthropic", "admin-openai"]) {
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: p,
|
||||
surface: "insights",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
}
|
||||
});
|
||||
|
||||
it("proceeds for codex WITH an active document-class receipt", async () => {
|
||||
grant(["ai_insights_only"]);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "codex",
|
||||
surface: "insights",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// Gate A: the documentsAutoAiRead opt-in short-circuits an external pick.
|
||||
it("proceeds for an external pick when documentsAutoAiRead is ON, without a receipt", async () => {
|
||||
grant([]);
|
||||
mockedAutoRead.mockResolvedValue(true);
|
||||
for (const p of ["codex", "openai", "anthropic", "admin-openai"]) {
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: p,
|
||||
surface: "insights",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
// The toggle short-circuits BEFORE the receipt read.
|
||||
expect(mockedLatest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("STILL requires a receipt for an external pick when documentsAutoAiRead is OFF", async () => {
|
||||
grant([]);
|
||||
mockedAutoRead.mockResolvedValue(false);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "codex",
|
||||
surface: "insights",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConsentRequiredError);
|
||||
});
|
||||
|
||||
it("does NOT consult the toggle for a LOCAL pick even when it would be ON", async () => {
|
||||
grant([]);
|
||||
mockedAutoRead.mockResolvedValue(true);
|
||||
await expect(
|
||||
assertDocumentEgressConsent({
|
||||
userId: "u1",
|
||||
providerType: "local",
|
||||
surface: "insights",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockedAutoRead).not.toHaveBeenCalled();
|
||||
// The document-class receipt (`ai_extraction` or `ai_full`) is read by the
|
||||
// capability egress re-check, not here: see
|
||||
// `src/lib/ai/capabilities/__tests__/egress.test.ts` and
|
||||
// `tests/integration/consent-withdrawal-purge.test.ts`. What this module still
|
||||
// owes the document surfaces is the local/external split above, and that the
|
||||
// extraction kind satisfies neither chain surface.
|
||||
describe("an extraction receipt does not satisfy the chain surfaces", () => {
|
||||
it("satisfies neither the Coach nor the analysis", async () => {
|
||||
grant(["ai_extraction"]);
|
||||
await expect(hasActiveConsentForSurface("u1", "coach")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(hasActiveConsentForSurface("u1", "insights")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -166,3 +166,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<AiCapabilityState> {
|
||||
return getEvent()?.getAuth()?.user_id
|
||||
? getAiCapability(key, { recordId })
|
||||
: aiCapabilityForJob(recordId, key);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("../conversation-summary", () => ({
|
||||
refreshConversationSummary: vi.fn(async () => ({ status: "refreshed" })),
|
||||
}));
|
||||
vi.mock("../facts", () => ({
|
||||
extractAndStoreFacts: vi.fn(async () => ({ status: "stored", count: 1 })),
|
||||
}));
|
||||
vi.mock("../plans", () => ({
|
||||
extractAndStorePlanProposals: vi.fn(async () => ({
|
||||
status: "stored",
|
||||
count: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { runCoachMemoryRefresh } from "../coach-memory-refresh-worker";
|
||||
import { refreshConversationSummary } from "../conversation-summary";
|
||||
import { extractAndStoreFacts } from "../facts";
|
||||
import { extractAndStorePlanProposals } from "../plans";
|
||||
|
||||
const payload = { conversationId: "c1", userId: "u1", locale: "en" as const };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("runCoachMemoryRefresh", () => {
|
||||
it("runs all three steps when the Coach is available", async () => {
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
await runCoachMemoryRefresh(payload);
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "coach");
|
||||
expect(refreshConversationSummary).toHaveBeenCalled();
|
||||
expect(extractAndStoreFacts).toHaveBeenCalled();
|
||||
expect(extractAndStorePlanProposals).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "user_disabled", "consent_required"] as const)(
|
||||
"reads no transcript and runs no step when the Coach is unavailable (%s)",
|
||||
async (reason) => {
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
await runCoachMemoryRefresh(payload);
|
||||
expect(refreshConversationSummary).not.toHaveBeenCalled();
|
||||
expect(extractAndStoreFacts).not.toHaveBeenCalled();
|
||||
expect(extractAndStorePlanProposals).not.toHaveBeenCalled();
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "coach.memory.refresh.skipped" },
|
||||
meta: { reason },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
* chokepoint did no budget work at all, so every generator behind it, not just
|
||||
* these two, spent unmetered.
|
||||
*/
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import type { CoachMemoryRefreshPayload } from "./coach-memory-shared";
|
||||
@@ -31,6 +32,19 @@ export async function runCoachMemoryRefresh(
|
||||
// fallback body for every locale that has no reviewed one.
|
||||
const locale = payload.locale ?? "en";
|
||||
|
||||
// The `coach` capability before any transcript is read. The chat route that
|
||||
// enqueued this admitted the Coach, but the operator, the person's Coach
|
||||
// switch or a consent can change while the job waits. Stored memory stays
|
||||
// as it is; only new model work stops. The chokepoint re-checks per step.
|
||||
const capability = await aiCapabilityForJob(userId, "coach");
|
||||
if (!capability.available) {
|
||||
annotate({
|
||||
action: { name: "coach.memory.refresh.skipped" },
|
||||
meta: { reason: capability.reason },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let summaryStatus = "error";
|
||||
try {
|
||||
const result = await refreshConversationSummary(conversationId, userId, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
* mapped kind OR the superset `ai_full` grant satisfies the gate.
|
||||
*/
|
||||
import { latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
|
||||
import type { ConsentKind } from "@/lib/validations/consent";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
|
||||
/**
|
||||
* The two AI surfaces that egress PHI. Each maps to the consent kind the iOS
|
||||
* client collects for it; `ai_full` (the master grant) satisfies either.
|
||||
* The two AI surfaces that egress PHI through a chain. Each maps to the consent
|
||||
* kind the iOS client collects for it; `ai_full` (the master grant) satisfies
|
||||
* either. Reading documents has its own kind (`ai_extraction`), checked by the
|
||||
* capability egress re-check rather than here.
|
||||
*/
|
||||
export type ConsentSurface = "coach" | "insights";
|
||||
|
||||
@@ -141,7 +142,11 @@ export async function assertConsentForChain(args: {
|
||||
* AI routes call the single resolved provider directly (no runner cascade), so
|
||||
* the exact egress is the picked provider. A `local` pick never leaves the
|
||||
* machine and stays ungated; EVERY external pick (codex, BYOK openai/anthropic,
|
||||
* the operator's admin key) requires an active document-class consent receipt.
|
||||
* the operator's admin key) requires an active extraction receipt
|
||||
* (`ai_extraction` or `ai_full`). The check itself runs through the capability
|
||||
* egress re-check (`aiEgressRefusal` in `@/lib/ai/capabilities/egress`), which
|
||||
* reads the receipt kinds from the capability table; the auto-read toggle is a
|
||||
* trigger, never the consent, so a revoked receipt wins over a toggle left on.
|
||||
*/
|
||||
const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
|
||||
|
||||
@@ -153,33 +158,3 @@ const LOCAL_ONLY_PROVIDER_TYPES: ReadonlySet<string> = new Set(["local"]);
|
||||
export function isExternalDocumentEgress(providerType: string): boolean {
|
||||
return !LOCAL_ONLY_PROVIDER_TYPES.has(providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the document-class consent precondition for the provider that will
|
||||
* actually receive the document. No-op for a `local` pick (nothing leaves the
|
||||
* machine); for any external pick, throw `ConsentRequiredError` unless an active
|
||||
* receipt of the surface's mapped kind (or `ai_full`) is on file.
|
||||
*
|
||||
* ONE relaxation: the per-user `documentsAutoAiRead` opt-in. Flipping it ON is
|
||||
* itself the standing consent act (the toggle write also mints an `ai_full`
|
||||
* receipt for the audit trail), so the gate short-circuits an external pick when
|
||||
* it is ON — that is what removes the per-document friction ("upload and the AI
|
||||
* just reads it, no 80 switches"). When it is OFF the branch is inert and the
|
||||
* gate behaves exactly as shipped: no external egress without an explicit
|
||||
* receipt. The short-circuit is checked BEFORE the receipt read and ONLY for an
|
||||
* external pick — a `local` pick is already ungated above, so the toggle can
|
||||
* never widen egress that was not already external.
|
||||
*
|
||||
* Call this AFTER the document provider is picked and BEFORE the first
|
||||
* `generateCompletion` on it.
|
||||
*/
|
||||
export async function assertDocumentEgressConsent(args: {
|
||||
userId: string;
|
||||
providerType: string;
|
||||
surface: ConsentSurface;
|
||||
}): Promise<void> {
|
||||
if (!isExternalDocumentEgress(args.providerType)) return;
|
||||
if (await documentAutoReadEnabled(args.userId)) return;
|
||||
if (await hasActiveConsentForSurface(args.userId, args.surface)) return;
|
||||
throw new ConsentRequiredError(args.surface);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside
|
||||
// a transaction. Run the callback against the same mock proxy. The Prisma
|
||||
// `createReceipt` runs its supersede logic inside a transaction. Run the callback against the same mock proxy. The Prisma
|
||||
// `$transaction` signature is generic; the mock takes a loose callback.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
createReceipt,
|
||||
latestActiveReceipt,
|
||||
latestActiveReceiptsByKind,
|
||||
revokeLatest,
|
||||
} from "../receipts";
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
@@ -196,39 +194,3 @@ describe("latestActiveReceiptsByKind", () => {
|
||||
expect(result.ai_insights_only).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeLatest", () => {
|
||||
it("atomically revokes the active row via updateMany and returns it", async () => {
|
||||
const now = new Date("2026-05-18T11:00:00.000Z");
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
row({ revokedAt: now }) as never,
|
||||
);
|
||||
|
||||
const result = await revokeLatest("user-1", "ai_full", now);
|
||||
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
// Re-reads the just-revoked row so the audit log keeps the receipt id.
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(result?.revokedAt).toEqual(now);
|
||||
});
|
||||
|
||||
it("returns null without re-reading when no active receipt exists", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const result = await revokeLatest("user-1", "ai_full");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The revoke and the purge run inside one transaction; the mock runs the
|
||||
// callback against the same proxy. Atomicity against a real database is
|
||||
// pinned by `tests/integration/consent-withdrawal-purge.test.ts`.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
updateMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withdrawConsent, type AiTextPurgeCounts } from "../withdrawal";
|
||||
|
||||
const COUNTS: AiTextPurgeCounts = {
|
||||
statusNotes: 3,
|
||||
briefing: 1,
|
||||
narratives: 2,
|
||||
reactionLines: 1,
|
||||
workoutParagraphs: 1,
|
||||
};
|
||||
|
||||
const now = new Date("2026-09-20T08:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt })) as never);
|
||||
});
|
||||
|
||||
/** Receipts active before the call, by kind. */
|
||||
function active(kinds: string[]) {
|
||||
const live = new Set(kinds);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockImplementation((async (args: {
|
||||
where: { kind: string };
|
||||
}) => {
|
||||
const had = live.delete(args.where.kind);
|
||||
return { count: had ? 1 : 0 };
|
||||
}) as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async (args: {
|
||||
where: { kind: string };
|
||||
}) => ({ id: `r-${args.where.kind}`, kind: args.where.kind })) as never);
|
||||
vi.mocked(prisma.consentReceipt.count).mockImplementation(
|
||||
(async (args: { where: { kind: { in: string[] } } }) =>
|
||||
args.where.kind.in.filter((k) => live.has(k)).length) as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe("withdrawConsent", () => {
|
||||
it("revokes each active kind conditionally and re-reads it for the audit trail", async () => {
|
||||
active(["ai_coach"]);
|
||||
const purge = vi.fn();
|
||||
const result = await withdrawConsent("u1", ["ai_coach"], now, purge);
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "u1", kind: "ai_coach", revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
expect(result.revoked.map((r) => r.kind)).toEqual(["ai_coach"]);
|
||||
});
|
||||
|
||||
it("purges when the revoke leaves no receipt covering the analysis", async () => {
|
||||
active(["ai_full"]);
|
||||
const purge = vi.fn(async () => COUNTS);
|
||||
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
|
||||
expect(purge).toHaveBeenCalledTimes(1);
|
||||
expect(result.purged).toEqual(COUNTS);
|
||||
});
|
||||
|
||||
it("keeps the text while another receipt still covers the analysis", async () => {
|
||||
active(["ai_full", "ai_insights_only"]);
|
||||
const purge = vi.fn(async () => COUNTS);
|
||||
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
|
||||
expect(purge).not.toHaveBeenCalled();
|
||||
expect(result.purged).toBeNull();
|
||||
});
|
||||
|
||||
it("never purges for a revoke that did not touch the analysis (Coach or extraction only)", async () => {
|
||||
active(["ai_coach", "ai_extraction"]);
|
||||
const purge = vi.fn(async () => COUNTS);
|
||||
const result = await withdrawConsent(
|
||||
"u1",
|
||||
["ai_coach", "ai_extraction"],
|
||||
now,
|
||||
purge,
|
||||
);
|
||||
expect(purge).not.toHaveBeenCalled();
|
||||
expect(result.purged).toBeNull();
|
||||
expect(result.revoked).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does nothing, and purges nothing, when no receipt was active", async () => {
|
||||
active([]);
|
||||
const purge = vi.fn(async () => COUNTS);
|
||||
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
|
||||
expect(result).toEqual({ revoked: [], purged: null });
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
expect(purge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a purge failure out of the transaction (the revoke rolls back with it)", async () => {
|
||||
active(["ai_insights_only"]);
|
||||
const purge = vi.fn(async () => {
|
||||
throw new Error("purge failed");
|
||||
});
|
||||
await expect(
|
||||
withdrawConsent("u1", ["ai_insights_only"], now, purge),
|
||||
).rejects.toThrow("purge failed");
|
||||
});
|
||||
});
|
||||
+23
-37
@@ -2,10 +2,10 @@
|
||||
* v1.4.40 SB-10 — AI consent receipts CRUD helper.
|
||||
*
|
||||
* Append-only audit trail. Every grant + revoke mints a fresh row;
|
||||
* no updates, no deletes outside of cascade-on-user-delete. The
|
||||
* `revokeLatest` helper writes the current row's `revokedAt` and
|
||||
* returns it — that single field flip is the only mutation the
|
||||
* helper performs. A subsequent re-grant inserts a new row, so the
|
||||
* no updates, no deletes outside of cascade-on-user-delete. A
|
||||
* revocation (`withdrawConsent` in `./withdrawal`) writes the current
|
||||
* row's `revokedAt` — that single field flip is the only mutation a
|
||||
* receipt ever sees. A subsequent re-grant inserts a new row, so the
|
||||
* historical chain stays intact for legal review.
|
||||
*
|
||||
* Source of truth for "is AI active right now?" is
|
||||
@@ -19,6 +19,25 @@ import { isP2002 } from "@/lib/prisma-errors";
|
||||
|
||||
export type ConsentReceipt = ConsentReceiptModel;
|
||||
|
||||
/**
|
||||
* The consent kind for reading documents, lab report scans and typed
|
||||
* medication text with an external model. Minted by switching the document
|
||||
* auto-read on; required (or the master `ai_full`) by every extraction path.
|
||||
* It does not satisfy the Coach or the analysis.
|
||||
*/
|
||||
export const AI_EXTRACTION_CONSENT_KIND = "ai_extraction" satisfies ConsentKind;
|
||||
|
||||
/**
|
||||
* The kinds whose active receipt authorises model-written analysis over the
|
||||
* person's own readings (briefing, status notes, narratives, workout
|
||||
* paragraphs, reaction lines). When none of these is left active, the
|
||||
* regenerable text those surfaces stored is purged.
|
||||
*/
|
||||
export const INSIGHTS_CONSENT_KINDS: readonly ConsentKind[] = [
|
||||
"ai_insights_only",
|
||||
"ai_full",
|
||||
];
|
||||
|
||||
/**
|
||||
* Insert a fresh consent receipt for (userId, kind). The caller has
|
||||
* already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
|
||||
@@ -119,36 +138,3 @@ export async function latestActiveReceiptsByKind(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the active receipt for (userId, kind) as revoked. Returns the
|
||||
* updated row, or `null` if no active receipt exists.
|
||||
*
|
||||
* v1.16.16 — the partial unique index guarantees at most one active row
|
||||
* per (user, kind), so a single atomic `updateMany` over the active
|
||||
* predicate revokes "the" active receipt without a separate read. Two
|
||||
* concurrent revokes converge: the first flips `revoked_at`, the second's
|
||||
* predicate no longer matches and writes nothing. The follow-up read
|
||||
* returns the row the caller (audit log) needs; it is `null` only when no
|
||||
* active receipt existed at all.
|
||||
*/
|
||||
export async function revokeLatest(
|
||||
userId: string,
|
||||
kind: ConsentKind,
|
||||
now: Date = new Date(),
|
||||
): Promise<ConsentReceipt | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (updated.count === 0) return null;
|
||||
// Re-read the just-revoked row (revoked at exactly `now`) so the audit
|
||||
// trail keeps the receipt id. The active predicate guaranteed a single
|
||||
// row, so this resolves it unambiguously.
|
||||
return tx.consentReceipt.findFirst({
|
||||
where: { userId, kind, revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Consent withdrawal: revoking receipts, and deleting the model-written text
|
||||
* that the withdrawn consent covered.
|
||||
*
|
||||
* A revocation stops new AI work at once (every capability reads the active
|
||||
* receipts). What it also owes the person is the text already written under
|
||||
* that consent. Two kinds of stored text are treated differently:
|
||||
*
|
||||
* - Regenerable caches are DELETED, in the same transaction as the revoke,
|
||||
* when the revoke leaves no active receipt covering the analysis
|
||||
* (`ai_insights_only` or `ai_full`): the per-metric status notes, the
|
||||
* cached briefing, model-written period narratives (the deterministic ones
|
||||
* are data and stay), arrival reaction lines (the markers stay), and the
|
||||
* workout paragraphs. Nothing of value is lost; granting consent again
|
||||
* regenerates them.
|
||||
* - The person's own records stay: Coach conversations, facts and plans,
|
||||
* document summaries and extracted text. They are hidden from AI surfaces
|
||||
* while a capability is unavailable, but they remain the person's to read
|
||||
* and delete.
|
||||
*
|
||||
* Revoke and purge commit together or not at all: a purge that fails leaves
|
||||
* the receipt active, so the person sees the withdrawal fail and can retry,
|
||||
* rather than a revoked consent with its text still stored.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DETERMINISTIC_PROVIDER_TYPE } from "@/lib/insights/narrative/period-narrative-deterministic";
|
||||
import type { ConsentKind } from "@/lib/validations/consent";
|
||||
|
||||
import { INSIGHTS_CONSENT_KINDS, type ConsentReceipt } from "./receipts";
|
||||
|
||||
/** How many stored items one withdrawal deleted, per surface. */
|
||||
export interface AiTextPurgeCounts {
|
||||
statusNotes: number;
|
||||
/** 1 when a cached briefing was cleared, else 0. */
|
||||
briefing: number;
|
||||
narratives: number;
|
||||
reactionLines: number;
|
||||
workoutParagraphs: number;
|
||||
}
|
||||
|
||||
export interface ConsentWithdrawal {
|
||||
/** The receipts this call revoked, in the order of `kinds`. */
|
||||
revoked: Array<{ kind: ConsentKind; receipt: ConsentReceipt }>;
|
||||
/** What was deleted, or `null` when the withdrawal left the analysis covered. */
|
||||
purged: AiTextPurgeCounts | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the regenerable model-written text for one account, inside `tx`.
|
||||
* Exported for the transaction below and its tests; call it through
|
||||
* `withdrawConsent`, never on its own.
|
||||
*/
|
||||
export async function purgeRegenerableAiText(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<AiTextPurgeCounts> {
|
||||
const [statusNotes, briefing, narratives, reactionLines, workoutParagraphs] =
|
||||
await Promise.all([
|
||||
tx.insightStatusCache.deleteMany({ where: { userId } }),
|
||||
tx.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
OR: [
|
||||
{ insightsCachedText: { not: null } },
|
||||
{ insightsCachedAt: { not: null } },
|
||||
{ insightsSnapshotHash: { not: null } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
insightsSnapshotHash: null,
|
||||
},
|
||||
}),
|
||||
tx.insightNarrative.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ providerType: null },
|
||||
{ providerType: { not: DETERMINISTIC_PROVIDER_TYPE } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
// The marker is data (it drives the "just in" chip); only the sentence
|
||||
// goes. `generatedAt` stays, so the worker does not write it again.
|
||||
tx.arrivalReaction.updateMany({
|
||||
where: { userId, lineEncrypted: { not: null } },
|
||||
data: { lineEncrypted: null },
|
||||
}),
|
||||
tx.workoutInsight.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
return {
|
||||
statusNotes: statusNotes.count,
|
||||
briefing: briefing.count,
|
||||
narratives: narratives.count,
|
||||
reactionLines: reactionLines.count,
|
||||
workoutParagraphs: workoutParagraphs.count,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the active receipt of each of `kinds`, and purge the regenerable
|
||||
* text when that leaves the analysis without a covering receipt. One
|
||||
* transaction. The purge runs only when this call revoked a receipt that
|
||||
* covered the analysis; revoking the Coach consent alone never deletes a
|
||||
* status note.
|
||||
*/
|
||||
export async function withdrawConsent(
|
||||
userId: string,
|
||||
kinds: readonly ConsentKind[],
|
||||
now: Date = new Date(),
|
||||
purge: typeof purgeRegenerableAiText = purgeRegenerableAiText,
|
||||
): Promise<ConsentWithdrawal> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const revoked: ConsentWithdrawal["revoked"] = [];
|
||||
for (const kind of kinds) {
|
||||
// The partial unique index keeps at most one active row per (user,
|
||||
// kind), so one conditional update revokes "the" active receipt; a
|
||||
// concurrent revoke finds nothing left to match and writes nothing.
|
||||
const updated = await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (updated.count === 0) continue;
|
||||
const receipt = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind, revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (receipt) revoked.push({ kind, receipt });
|
||||
}
|
||||
|
||||
const touchedAnalysis = revoked.some(({ kind }) =>
|
||||
INSIGHTS_CONSENT_KINDS.includes(kind),
|
||||
);
|
||||
if (!touchedAnalysis) return { revoked, purged: null };
|
||||
|
||||
const stillCovered = await tx.consentReceipt.count({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
kind: { in: [...INSIGHTS_CONSENT_KINDS] },
|
||||
},
|
||||
});
|
||||
if (stillCovered > 0) return { revoked, purged: null };
|
||||
|
||||
return { revoked, purged: await purge(tx, userId) };
|
||||
});
|
||||
}
|
||||
@@ -232,6 +232,12 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
// ───── Insight narratives (Bytes column) ─────
|
||||
{ model: "InsightNarrative", field: "encryptedContent", kind: "bytes" },
|
||||
|
||||
// ───── v1.39.0 status notes (Bytes columns) ─────
|
||||
// Both nullable: a row can carry only a negative-cache window, and only the
|
||||
// medication-compliance card stores per-item lines.
|
||||
{ model: "InsightStatusCache", field: "textEncrypted", kind: "bytes" },
|
||||
{ model: "InsightStatusCache", field: "itemsEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── v1.31.0 data-arrival spine (Bytes column) ─────
|
||||
// Nullable: the reaction marker is written on every salient arrival, but the
|
||||
// generated line only exists where a provider was reachable and in budget.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -157,6 +157,7 @@ export const WIPE_MODELS = [
|
||||
"CoachReminder",
|
||||
"CoachUsage",
|
||||
"InsightNarrative",
|
||||
"InsightStatusCache",
|
||||
"ProviderHealth",
|
||||
|
||||
// ── Sharing ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* uploaded documents. OFF by default: the vault stays local-first and every
|
||||
* external egress needs an explicit per-document action + consent receipt.
|
||||
*
|
||||
* Two gates read this flag:
|
||||
* - the document consent gate (`assertDocumentEgressConsent`) short-circuits an
|
||||
* external pick when it is ON (the toggle IS the standing consent);
|
||||
* It is a trigger, not a consent. Switching it on mints an `ai_extraction`
|
||||
* receipt, and that receipt is what the document egress re-check reads, so a
|
||||
* revoked receipt stops external reading even with the flag still on. Readers:
|
||||
* - the summary job and its catch-up pass run only while it is ON;
|
||||
* - the auto-index-on-upload job stays strictly local when it is OFF and uses
|
||||
* the document-order external pick when it is ON.
|
||||
*/
|
||||
|
||||
@@ -582,6 +582,8 @@ export const DERIVED_MODELS: Readonly<Record<string, string>> = {
|
||||
"A concurrency claim guarding the generator above. Meaningless outside the run that took it.",
|
||||
InsightNarrative:
|
||||
"Generated prose over measurements. Same reasoning as WorkoutInsight — the record restores, the essay about it does not.",
|
||||
InsightStatusCache:
|
||||
"Regenerable cache: the per-metric status notes a model wrote over measurements, rewritten by the nightly run. Same reasoning as InsightNarrative.",
|
||||
DocumentContentIndex:
|
||||
"A search index over `InboundDocument` text; rebuilt by the indexer.",
|
||||
DocumentThumbnail:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,7 +11,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
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() },
|
||||
@@ -22,8 +26,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", () => ({
|
||||
@@ -39,6 +56,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;
|
||||
|
||||
@@ -62,6 +80,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.
|
||||
@@ -93,16 +116,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);
|
||||
@@ -146,7 +165,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() },
|
||||
@@ -156,9 +174,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",
|
||||
@@ -172,23 +187,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",
|
||||
@@ -196,16 +208,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 },
|
||||
@@ -215,9 +226,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."}');
|
||||
|
||||
@@ -236,7 +244,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() },
|
||||
@@ -246,9 +253,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"}',
|
||||
@@ -260,12 +264,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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,7 +310,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 },
|
||||
@@ -322,9 +322,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",
|
||||
|
||||
@@ -11,7 +11,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
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() },
|
||||
@@ -20,8 +24,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", () => ({
|
||||
@@ -32,6 +49,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;
|
||||
|
||||
@@ -56,6 +74,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);
|
||||
@@ -79,11 +102,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);
|
||||
@@ -127,15 +146,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",
|
||||
@@ -159,13 +176,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",
|
||||
@@ -180,25 +193,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);
|
||||
@@ -211,23 +220,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",
|
||||
@@ -236,22 +242,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."}');
|
||||
|
||||
@@ -269,13 +271,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."}',
|
||||
@@ -285,11 +283,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:");
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,20 @@ 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.
|
||||
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
|
||||
// arm has its own test in comprehensive-budget-refusal.test.ts.
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(async () => null),
|
||||
}));
|
||||
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),
|
||||
|
||||
@@ -40,6 +40,20 @@ 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.
|
||||
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
|
||||
// arm has its own test in comprehensive-budget-refusal.test.ts.
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(async () => null),
|
||||
}));
|
||||
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),
|
||||
|
||||
@@ -25,7 +25,8 @@ const resolveProvider = vi.fn();
|
||||
const runRawCompletionWithFallback = vi.fn();
|
||||
const extractFeatures = vi.fn();
|
||||
const recordBriefingFailure = vi.fn();
|
||||
const hasActiveConsentForSurface = vi.fn();
|
||||
const hasReceipt = vi.fn();
|
||||
const aiEgressRefusal = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
@@ -69,6 +70,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),
|
||||
@@ -104,18 +114,16 @@ vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserInsights: vi.fn(),
|
||||
}));
|
||||
// Modelled truthfully rather than stubbed open: only an operator-credential
|
||||
// chain needs a receipt, which is what lets the consent-ordering test below
|
||||
// assert the real gate rather than a bypass.
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
chainRequiresServerManagedConsent: (chain: { providerType: string }[] = []) =>
|
||||
chain.some((c) => c.providerType.startsWith("admin-")),
|
||||
hasActiveConsentForSurface: (...a: unknown[]) =>
|
||||
hasActiveConsentForSurface(...a),
|
||||
// The wire re-check, modelled truthfully rather than stubbed open: only an
|
||||
// operator-credential entry needs a receipt, which is what lets the
|
||||
// consent-ordering test below assert the real gate rather than a bypass.
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a),
|
||||
}));
|
||||
|
||||
import { generateComprehensiveInsight } from "../comprehensive-generate";
|
||||
import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } });
|
||||
|
||||
@@ -139,7 +147,13 @@ beforeEach(() => {
|
||||
insightsSnapshotHash: null,
|
||||
insightsBriefingRerollDate: null,
|
||||
});
|
||||
hasActiveConsentForSurface.mockResolvedValue(true);
|
||||
hasReceipt.mockResolvedValue(true);
|
||||
aiEgressRefusal.mockImplementation(
|
||||
async (key: never, _userId: string, providerTypes: string[]) =>
|
||||
providerTypes.some((t) => t.startsWith("admin-")) && !(await hasReceipt())
|
||||
? new AiUnavailableError(key, "consent_required")
|
||||
: null,
|
||||
);
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content: VALID, tokensUsed: 900, model: "m" },
|
||||
workingProvider: { providerType: "admin-openai" },
|
||||
@@ -215,11 +229,28 @@ describe("generateComprehensiveInsight — daily token ceiling", () => {
|
||||
// Reserving first would bill an operator-key user for a snapshot that is
|
||||
// never sent anywhere, and could lock them out of a tier they are not
|
||||
// even permitted to use.
|
||||
hasActiveConsentForSurface.mockResolvedValue(false);
|
||||
hasReceipt.mockResolvedValue(false);
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "no-consent" });
|
||||
expect(aiEgressRefusal).toHaveBeenCalledWith("briefing", "u1", [
|
||||
"admin-openai",
|
||||
]);
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(ledgerTotal).toBe(0);
|
||||
});
|
||||
|
||||
it("reports any other wire refusal as `skipped: unavailable`, before any spend", async () => {
|
||||
// A switch the operator turned off after the run was scheduled: not a
|
||||
// consent question, so not reported as one.
|
||||
aiEgressRefusal.mockResolvedValue(
|
||||
new AiUnavailableError("briefing", "operator_disabled"),
|
||||
);
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "unavailable" });
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(ledgerTotal).toBe(0);
|
||||
});
|
||||
|
||||
@@ -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,14 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
|
||||
// arm has its own test in comprehensive-budget-refusal.test.ts.
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(async () => null),
|
||||
}));
|
||||
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 +117,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", () => {
|
||||
|
||||
@@ -38,6 +38,20 @@ 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.
|
||||
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
|
||||
// arm has its own test in comprehensive-budget-refusal.test.ts.
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(async () => null),
|
||||
}));
|
||||
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),
|
||||
|
||||
@@ -37,6 +37,20 @@ 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.
|
||||
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
|
||||
// arm has its own test in comprehensive-budget-refusal.test.ts.
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(async () => null),
|
||||
}));
|
||||
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),
|
||||
|
||||
@@ -42,6 +42,20 @@ 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.
|
||||
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
|
||||
// arm has its own test in comprehensive-budget-refusal.test.ts.
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(async () => null),
|
||||
}));
|
||||
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),
|
||||
|
||||
@@ -44,6 +44,20 @@ 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.
|
||||
// The wire re-check (`aiEgressRefusal`) passes in these fixtures; its refusal
|
||||
// arm has its own test in comprehensive-budget-refusal.test.ts.
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: vi.fn(async () => null),
|
||||
}));
|
||||
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),
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string> };
|
||||
};
|
||||
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.");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
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() },
|
||||
},
|
||||
@@ -19,8 +23,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", () => ({
|
||||
@@ -60,6 +77,7 @@ import {
|
||||
metricStatusScope,
|
||||
METRIC_STATUS_IDS,
|
||||
} from "../metric-status-registry";
|
||||
import { writtenNotes } from "./status-note-fixtures";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -86,6 +104,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
|
||||
@@ -98,14 +121,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).
|
||||
@@ -169,14 +188,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);
|
||||
@@ -337,7 +352,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({
|
||||
@@ -366,16 +380,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;
|
||||
@@ -404,12 +414,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
|
||||
@@ -432,16 +439,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:
|
||||
@@ -472,7 +475,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() },
|
||||
@@ -481,9 +483,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."}',
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
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() },
|
||||
@@ -35,8 +39,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", () => ({
|
||||
@@ -76,11 +93,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,
|
||||
|
||||
@@ -12,11 +12,11 @@ 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.
|
||||
*/
|
||||
|
||||
// The graded series folds per-day aggregates in SQL; the fake folds the
|
||||
@@ -30,7 +30,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
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
|
||||
@@ -42,8 +46,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", () => ({
|
||||
@@ -57,6 +74,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);
|
||||
});
|
||||
@@ -67,7 +89,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);
|
||||
@@ -94,19 +115,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<string, string> };
|
||||
create: Record<string, unknown>;
|
||||
update: Record<string, unknown>;
|
||||
};
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
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
|
||||
@@ -23,8 +27,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", () => ({
|
||||
@@ -35,6 +52,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;
|
||||
|
||||
@@ -58,6 +76,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.
|
||||
@@ -79,7 +102,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
|
||||
@@ -90,9 +112,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);
|
||||
@@ -154,16 +173,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);
|
||||
@@ -202,15 +217,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);
|
||||
@@ -242,15 +253,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",
|
||||
@@ -267,8 +276,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",
|
||||
@@ -277,23 +286,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."}');
|
||||
|
||||
@@ -311,14 +316,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."}',
|
||||
@@ -328,12 +329,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.");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, 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<string, string> };
|
||||
create: Record<string, unknown>;
|
||||
update: Record<string, unknown>;
|
||||
};
|
||||
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<string, unknown> };
|
||||
// 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<string, string>;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.data.userId).toBe("u1");
|
||||
expect(arg.data.action).toBe("insights.weight-status.en");
|
||||
const details = JSON.parse(arg.data.details) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
}
|
||||
).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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> }).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);
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -74,15 +85,16 @@ vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
runRawCompletionWithFallback,
|
||||
}));
|
||||
|
||||
// Consent gate is exercised by its own suite; keep it open here so the budget
|
||||
// behaviour is what these tests isolate.
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
chainRequiresServerManagedConsent: vi.fn(() => false),
|
||||
hasActiveConsentForSurface: vi.fn(async () => true),
|
||||
}));
|
||||
// The wire re-check for the resolved chain (`aiEgressRefusal`). Its own rule
|
||||
// (capability + receipt for an operator-held entry) is pinned in egress.test.ts;
|
||||
// here it passes by default so the budget is what these tests isolate, and
|
||||
// the refusal test below flips it.
|
||||
const { aiEgressRefusal } = vi.hoisted(() => ({ aiEgressRefusal: vi.fn() }));
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({ aiEgressRefusal }));
|
||||
|
||||
import { runStatusCompletion } from "../status-provider";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget";
|
||||
|
||||
const OPERATOR_CHAIN = [{ providerType: "admin-openai", instance: {} }];
|
||||
@@ -94,7 +106,7 @@ function completionArgs(overrides: Record<string, unknown> = {}) {
|
||||
cacheAction: "status:test",
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
consentSurface: "insights" as const,
|
||||
capability: "statusText" as const,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -113,6 +125,72 @@ beforeEach(() => {
|
||||
ledgerOperator = 0;
|
||||
resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN);
|
||||
resolveProvider.mockResolvedValue({ type: "none" });
|
||||
aiCapabilityForRecord.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
aiEgressRefusal.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
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("re-checks exactly the resolved chain and refuses before any budget or provider work", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
{ providerType: "admin-openai", instance: {} },
|
||||
]);
|
||||
aiEgressRefusal.mockResolvedValue(
|
||||
new AiUnavailableError("coach", "consent_required"),
|
||||
);
|
||||
|
||||
const outcome = await runStatusCompletion(
|
||||
completionArgs({ capability: "coach" }) as never,
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ kind: "none", reason: "consent_required" });
|
||||
expect(aiEgressRefusal).toHaveBeenCalledWith("coach", "u1", [
|
||||
"anthropic",
|
||||
"admin-openai",
|
||||
]);
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(ledgerTotal).toBe(0);
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "insights.status.capability_unavailable" },
|
||||
meta: expect.objectContaining({
|
||||
reason: "consent_required",
|
||||
at: "wire",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -10,7 +10,11 @@ vi.mock("@/lib/measurements/day-aggregates", async () => ({
|
||||
|
||||
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() },
|
||||
@@ -26,10 +30,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<typeof import("@/lib/ai/provider")>()),
|
||||
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(""),
|
||||
@@ -38,6 +71,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;
|
||||
|
||||
@@ -76,6 +111,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", () => {
|
||||
@@ -94,12 +172,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);
|
||||
@@ -135,14 +209,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",
|
||||
@@ -156,44 +226,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."}');
|
||||
|
||||
@@ -231,10 +295,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.
|
||||
@@ -243,33 +303,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",
|
||||
@@ -279,15 +330,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<string, unknown>;
|
||||
}
|
||||
).data;
|
||||
expect(refreshed).not.toHaveProperty("snapshotHash");
|
||||
expect(refreshed).not.toHaveProperty("textEncrypted");
|
||||
expect(refreshed.dateKey).not.toBe("2026-06-09");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -300,21 +353,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", {
|
||||
@@ -330,14 +376,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"}',
|
||||
@@ -349,11 +391,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:");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,10 +78,8 @@ import {
|
||||
BriefingBudgetExceededError,
|
||||
runBriefingCompletion,
|
||||
} from "@/lib/insights/briefing-provider";
|
||||
import {
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { aiCapabilityForRecord } from "@/lib/ai/capabilities/gate";
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import { invalidateUserInsights } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
stripJsonFences,
|
||||
@@ -142,6 +140,8 @@ export type GenerateOutcome =
|
||||
| {
|
||||
status: "skipped";
|
||||
reason:
|
||||
/** The `briefing` capability is unavailable for another reason. */
|
||||
| "unavailable"
|
||||
| "no-provider"
|
||||
| "no-consent"
|
||||
| "budget"
|
||||
@@ -657,6 +657,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);
|
||||
@@ -666,16 +687,23 @@ export async function generateComprehensiveInsight(
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
|
||||
// v1.12.1 — consent gate before any server-managed external egress. This
|
||||
// The wire re-check for exactly this chain (`aiEgressRefusal`): the
|
||||
// capability again, and the consent an operator-held entry needs. This
|
||||
// pipeline runs off-request (nightly cron + on-demand force-warm), so a
|
||||
// missing receipt is a typed `skipped` outcome rather than a throw — the
|
||||
// cron batch continues to the next user. BYOK / local / ChatGPT-OAuth
|
||||
// chains never trip the check.
|
||||
if (
|
||||
chainRequiresServerManagedConsent(chain) &&
|
||||
!(await hasActiveConsentForSurface(userId, "insights"))
|
||||
) {
|
||||
return { status: "skipped", reason: "no-consent" };
|
||||
// refusal is a typed `skipped` outcome rather than a throw — the cron batch
|
||||
// continues to the next user. A person's own key, their ChatGPT account or
|
||||
// a local model need no receipt.
|
||||
const refusal = await aiEgressRefusal(
|
||||
"briefing",
|
||||
userId,
|
||||
chain.map((entry) => entry.providerType),
|
||||
);
|
||||
if (refusal) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason:
|
||||
refusal.reason === "consent_required" ? "no-consent" : "unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
+16
-46
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<ArrayBuffer> {
|
||||
const ciphertext = encrypt(plaintext);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+432
-332
@@ -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.<metric>-status.<locale>` 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:<ID>` 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.<scope>-status.<locale>`, 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.<scope>-status.<locale>`. 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<StoredStatusNote | null> {
|
||||
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<boolean> {
|
||||
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<Date> {
|
||||
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<Date> {
|
||||
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<void> {
|
||||
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<FreshStatusCacheHit | null> {
|
||||
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<FreshStatusCacheHit | 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-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<FreshStatusCacheHit | null> {
|
||||
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<LastGoodStatusHit | null> {
|
||||
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<Set<string>> {
|
||||
const byKey = new Map<string, string>();
|
||||
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<string>();
|
||||
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: <no-key fallback> }`
|
||||
* - `preparing` → `{ hasProvider: true, text: <last-good|null>, 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<ReadOnlyMissOutcome> {
|
||||
// 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<StatusNegativeCache | 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;
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number> {
|
||||
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<InsightStatusScope>(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<InsightStatusScope>();
|
||||
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;
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
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,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import {
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
type ConsentSurface,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
buildDateKey,
|
||||
@@ -42,7 +47,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 +84,14 @@ 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 an operator-held chain entry needs.
|
||||
*/
|
||||
consentSurface: ConsentSurface;
|
||||
capability: AiCapabilityKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,42 +112,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<boolean> {
|
||||
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<boolean> {
|
||||
const chain = await resolveStatusChain(userId);
|
||||
if (chain === null) return false;
|
||||
return (
|
||||
chainRequiresServerManagedConsent(chain) &&
|
||||
!(await hasActiveConsentForSurface(userId, surface))
|
||||
);
|
||||
return probeProviderPresence(userId, "text");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,27 +135,46 @@ export async function runStatusCompletion(
|
||||
): Promise<StatusProviderResult> {
|
||||
const { userId, cacheAction, systemPrompt, userPrompt } = args;
|
||||
|
||||
const chain = await resolveStatusChain(userId);
|
||||
if (chain === null) {
|
||||
return { kind: "none" };
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// v1.12.1 — consent gate before server-managed external egress. A chain
|
||||
// that could egress via the operator's global key requires an active
|
||||
// receipt of the surface's mapped kind (or master `ai_full`). Without one,
|
||||
// surface the no-key fallback (`none`) rather than egress the snapshot —
|
||||
// identical to a missing provider from the caller's perspective, so no
|
||||
// generator branch needs to change. BYOK / local / ChatGPT-OAuth chains
|
||||
// never trip this.
|
||||
if (
|
||||
chainRequiresServerManagedConsent(chain) &&
|
||||
!(await hasActiveConsentForSurface(userId, args.consentSurface))
|
||||
) {
|
||||
const chain = await resolveStatusChain(userId);
|
||||
if (chain === null) {
|
||||
return { kind: "none", reason: "no_provider" };
|
||||
}
|
||||
|
||||
// The wire re-check for exactly this chain (`aiEgressRefusal`, the one
|
||||
// helper every chokepoint uses): the capability again, and whether sending
|
||||
// to these providers needs a consent receipt under the capability's rule
|
||||
// (the operator's key or the shared central Codex does; a person's own key
|
||||
// or a local model does not). Fails closed like the rest: a chain that
|
||||
// COULD cascade to an operator-held entry needs the receipt.
|
||||
const refusal = await aiEgressRefusal(
|
||||
args.capability,
|
||||
userId,
|
||||
chain.map((entry) => entry.providerType),
|
||||
);
|
||||
if (refusal) {
|
||||
annotate({
|
||||
action: { name: "insights.status.consent_required" },
|
||||
meta: { cacheAction, surface: args.consentSurface },
|
||||
action: { name: "insights.status.capability_unavailable" },
|
||||
meta: {
|
||||
cacheAction,
|
||||
capability: args.capability,
|
||||
reason: refusal.reason,
|
||||
at: "wire",
|
||||
},
|
||||
});
|
||||
return { kind: "none" };
|
||||
return { kind: "none", reason: refusal.reason };
|
||||
}
|
||||
|
||||
// Honour the per-user response-timeout setting the operator dials in for a
|
||||
|
||||
@@ -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<string> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Every background job that writes model text resolves its AI capability.
|
||||
*
|
||||
* The rule for jobs (design: "AI optional"): a worker resolves its capability
|
||||
* for the user before it builds a snapshot and skips with a `.skipped`
|
||||
* annotation when the answer is no; the provider chokepoint re-checks at the
|
||||
* wire. This freezes the first half as a reference: each file below must call
|
||||
* the named gate with the named capability key.
|
||||
*
|
||||
* It proves a reference exists, not that it sits before the snapshot or on
|
||||
* every branch. The integration suite (`tests/integration/ai-optional-jobs.test.ts`)
|
||||
* runs each worker under the refusing states with a provider spy and is what
|
||||
* proves behaviour.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
AI_CAPABILITY_KEYS,
|
||||
type AiCapabilityKey,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
|
||||
const ROOT = join(process.cwd(), "src");
|
||||
|
||||
type Gate =
|
||||
"aiCapabilityForJob" | "aiCapabilityForRecord" | "aiWorkNotRuledOut";
|
||||
|
||||
interface JobGate {
|
||||
gate: Gate;
|
||||
keys: readonly AiCapabilityKey[];
|
||||
}
|
||||
|
||||
/** Every AI job (and the enqueue sides that decide whether one is queued). */
|
||||
const AI_JOBS: Record<string, JobGate> = {
|
||||
"lib/jobs/insight-pregenerate.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["briefing", "statusText"],
|
||||
},
|
||||
"lib/insights/status-batch.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["statusText"],
|
||||
},
|
||||
"lib/jobs/reminder/insights-handlers.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["statusText"],
|
||||
},
|
||||
"lib/jobs/insight-status-generate.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["statusText"],
|
||||
},
|
||||
"lib/jobs/period-narrative-warm.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["periodNarrative"],
|
||||
},
|
||||
"lib/jobs/coach-nudge.ts": { gate: "aiCapabilityForJob", keys: ["coach"] },
|
||||
"lib/jobs/coach-nudge-ai.ts": { gate: "aiCapabilityForJob", keys: ["coach"] },
|
||||
"lib/ai/coach/coach-memory-refresh-worker.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["coach"],
|
||||
},
|
||||
"lib/jobs/reaction-line.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["reactionLines"],
|
||||
},
|
||||
"lib/jobs/workout-insight-generate.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["workoutInsights"],
|
||||
},
|
||||
"lib/jobs/document-summary.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["documentAi"],
|
||||
},
|
||||
"lib/jobs/document-summary-catchup.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["documentAi"],
|
||||
},
|
||||
"lib/jobs/document-content-index-backfill.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["documentAi"],
|
||||
},
|
||||
// The arrival spine may not import the provider machinery, so it asks the
|
||||
// provider-free half before it enqueues; the jobs resolve the rest.
|
||||
"lib/jobs/data-arrival.ts": {
|
||||
gate: "aiWorkNotRuledOut",
|
||||
keys: ["workoutInsights", "reactionLines"],
|
||||
},
|
||||
// Enqueue sides reached from requests and jobs alike.
|
||||
"lib/insights/status-invalidation.ts": {
|
||||
gate: "aiCapabilityForRecord",
|
||||
keys: ["statusText"],
|
||||
},
|
||||
};
|
||||
|
||||
function code(rel: string): string {
|
||||
return readFileSync(join(ROOT, rel), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
describe("AI job capability inventory", () => {
|
||||
const entries = Object.entries(AI_JOBS);
|
||||
|
||||
it("is not empty", () => {
|
||||
expect(entries.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("names only capabilities that exist", () => {
|
||||
for (const [, { keys }] of entries) {
|
||||
for (const key of keys) expect(AI_CAPABILITY_KEYS).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(entries)("%s resolves its capability", (rel, { gate, keys }) => {
|
||||
const source = code(rel);
|
||||
for (const key of keys) {
|
||||
const call = new RegExp(`\\b${gate}\\s*\\([^)]*?["']${key}["']`);
|
||||
expect(
|
||||
call.test(source),
|
||||
`${rel} must call ${gate}(…, "${key}") before it does model work`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -62,15 +62,9 @@ vi.mock("@/lib/documents/auto-stage-labs", () => ({
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "application/pdf"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => {
|
||||
class ConsentRequiredError extends Error {}
|
||||
return {
|
||||
ConsentRequiredError,
|
||||
assertDocumentEgressConsent: vi.fn(),
|
||||
isExternalDocumentEgress: (providerType: string) =>
|
||||
providerType !== "local",
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
isExternalDocumentEgress: (providerType: string) => providerType !== "local",
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-08-13"),
|
||||
reserveBudget: vi.fn(),
|
||||
@@ -79,6 +73,10 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: vi.fn(),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
AI_BUDGETS: {
|
||||
documentSummary: { temperature: 0.3, maxTokens: 600 },
|
||||
@@ -104,11 +102,12 @@ import {
|
||||
transcribeDocument,
|
||||
} from "@/lib/documents/describe";
|
||||
import { localExtractText } from "@/lib/documents/local-extract";
|
||||
import {
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
import {
|
||||
aiCapabilityForJob,
|
||||
aiCapabilityForRecord,
|
||||
} from "@/lib/ai/capabilities/gate";
|
||||
|
||||
const DOC = {
|
||||
id: "doc-1",
|
||||
@@ -121,6 +120,7 @@ const DOC = {
|
||||
|
||||
const PICK = {
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
withheld: null,
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
providerType: "anthropic",
|
||||
@@ -163,8 +163,14 @@ beforeEach(() => {
|
||||
locale: "en",
|
||||
} as never);
|
||||
vi.mocked(documentAutoReadEnabled).mockResolvedValue(true);
|
||||
for (const resolve of [aiCapabilityForJob, aiCapabilityForRecord]) {
|
||||
vi.mocked(resolve).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
}
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
|
||||
vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never);
|
||||
vi.mocked(prepareVisionInput).mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -205,21 +211,66 @@ describe("contract (a): every runDocumentSummaryJob branch writes a terminal sta
|
||||
state: "NONE",
|
||||
},
|
||||
{
|
||||
name: "no provider configured",
|
||||
// Presence says no provider, but that is the pick's to decide; with no
|
||||
// pick either, the attempt is recorded as one that could not run.
|
||||
name: "documentAi unavailable for want of a provider, no pick",
|
||||
setup: () => {
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "no_provider",
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: null,
|
||||
} as never);
|
||||
},
|
||||
state: "UNAVAILABLE",
|
||||
},
|
||||
{
|
||||
name: "consent refused",
|
||||
// Presence says a receipt is missing, but the document order lands on a
|
||||
// pick that needs none: the pick decides, and the summary is written.
|
||||
name: "documentAi presence wants a receipt the pick does not need",
|
||||
setup: () => {
|
||||
vi.mocked(assertDocumentEgressConsent).mockRejectedValue(
|
||||
new ConsentRequiredError("insights"),
|
||||
);
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "consent_required",
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
},
|
||||
state: "READY",
|
||||
},
|
||||
{
|
||||
name: "documentAi switched off by the operator",
|
||||
setup: () => {
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
},
|
||||
state: "NONE",
|
||||
},
|
||||
{
|
||||
name: "no provider configured",
|
||||
setup: () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: null,
|
||||
} as never);
|
||||
},
|
||||
state: "UNAVAILABLE",
|
||||
},
|
||||
{
|
||||
name: "consent refused at the pick's wire re-check",
|
||||
setup: () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: PICK.chain,
|
||||
pick: null,
|
||||
withheld: new AiUnavailableError("documentAi", "consent_required"),
|
||||
} as never);
|
||||
},
|
||||
state: "UNAVAILABLE",
|
||||
},
|
||||
@@ -309,6 +360,7 @@ describe("contract (a): every runDocumentIndex branch records the attempt", () =
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: null,
|
||||
} as never);
|
||||
},
|
||||
outcome: null,
|
||||
@@ -319,6 +371,7 @@ describe("contract (a): every runDocumentIndex branch records the attempt", () =
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: null,
|
||||
} as never);
|
||||
vi.mocked(localExtractText).mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -404,6 +457,39 @@ describe("contract (b): enqueue never claims a pending state its job cannot reso
|
||||
expect(summaryStateWrites()).toEqual([]);
|
||||
});
|
||||
|
||||
it("enqueueDocumentSummary with documentAi switched off claims nothing and mints no job", async () => {
|
||||
vi.mocked(aiCapabilityForRecord).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "operator_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const send = vi.fn().mockResolvedValue("job-1");
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
|
||||
const result = await enqueueDocumentSummary("user-1", "doc-1");
|
||||
expect(result).toEqual({ enqueued: false });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(summaryStateWrites()).toEqual([]);
|
||||
});
|
||||
|
||||
it("enqueueDocumentSummary leaves a missing receipt to the job's pick, whose every branch is terminal", async () => {
|
||||
// Presence cannot say whether the document-order pick needs a receipt, so
|
||||
// the enqueue does not refuse on it; contract (a) pins that the job then
|
||||
// writes a terminal state for both a withheld pick and a served one.
|
||||
vi.mocked(aiCapabilityForRecord).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "consent_required",
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
const send = vi.fn().mockResolvedValue("job-1");
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
|
||||
const result = await enqueueDocumentSummary("user-1", "doc-1");
|
||||
expect(result).toEqual({ enqueued: true });
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(summaryStateWrites()).toContain("PENDING");
|
||||
});
|
||||
|
||||
it("enqueueDocumentSummary claims PENDING only once a job id exists", async () => {
|
||||
const send = vi.fn().mockResolvedValue(null);
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
|
||||
@@ -10,15 +10,25 @@
|
||||
* The gate is skip-shaped: no receipt → `null` → the caller ships the
|
||||
* deterministic template, so the nudge itself is never lost.
|
||||
*
|
||||
* `@/lib/consent/receipts` is mocked rather than the consent guard, so the real
|
||||
* `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface` logic runs.
|
||||
* Only the receipt table (`prisma.consentReceipt`) is mocked, not the wire
|
||||
* re-check, so the real `aiEgressRefusal` rule runs: the capability again (a
|
||||
* job, so `aiCapabilityForJob`), then whether exactly this chain needs a
|
||||
* receipt under the `coach` capability's rule, then whether one is active.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
const latestActiveReceipt = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/consent/receipts", () => ({ latestActiveReceipt }));
|
||||
/** The active receipt kinds the record holds; `findFirst` answers from them. */
|
||||
const activeKinds = vi.hoisted(() => ({ current: [] as string[] }));
|
||||
const consentReceiptFindFirst = vi.hoisted(() =>
|
||||
vi.fn(async (args: { where: { kind: { in: string[] } } }) =>
|
||||
args.where.kind.in.some((kind) => activeKinds.current.includes(kind))
|
||||
? { id: "receipt-1" }
|
||||
: null,
|
||||
),
|
||||
);
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { consentReceipt: { findFirst: consentReceiptFindFirst } },
|
||||
}));
|
||||
vi.mock("@/lib/documents/document-settings", () => ({
|
||||
documentAutoReadEnabled: vi.fn(async () => false),
|
||||
}));
|
||||
@@ -40,13 +50,27 @@ const budgetMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
// No request event: the nudge runs off-request, which is the arm of
|
||||
// `aiEgressRefusal` that resolves the capability for the job's record.
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
// The composer resolves the `coach` capability before the chain. Available
|
||||
// by default so the consent logic below is what each case exercises.
|
||||
const aiCapabilityForJob = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob,
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
composeNudgeWithAI,
|
||||
createNudgeAiTickBudget,
|
||||
type ComposeNudgeParams,
|
||||
} from "../coach-nudge-ai";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
function makeProvider() {
|
||||
return {
|
||||
@@ -77,7 +101,12 @@ function params(): ComposeNudgeParams {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provider = makeProvider();
|
||||
latestActiveReceipt.mockResolvedValue(null);
|
||||
activeKinds.current = [];
|
||||
aiCapabilityForJob.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
budgetMocks.reserveBudget.mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: 160,
|
||||
@@ -125,9 +154,7 @@ describe("composeNudgeWithAI — server-managed consent gate", () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
|
||||
kind === "ai_coach" ? { id: "receipt-1" } : null,
|
||||
);
|
||||
activeKinds.current = ["ai_coach"];
|
||||
|
||||
const out = await composeNudgeWithAI(params());
|
||||
|
||||
@@ -142,14 +169,26 @@ describe("composeNudgeWithAI — server-managed consent gate", () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
|
||||
kind === "ai_full" ? { id: "receipt-2" } : null,
|
||||
);
|
||||
activeKinds.current = ["ai_full"];
|
||||
|
||||
expect(await composeNudgeWithAI(params())).not.toBeNull();
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not count a receipt of an unrelated kind", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
activeKinds.current = ["ai_extraction"];
|
||||
|
||||
expect(await composeNudgeWithAI(params())).toBeNull();
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "coach.nudge.ai.refused" },
|
||||
meta: { reason: "consent_required" },
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
@@ -157,7 +196,7 @@ describe("composeNudgeWithAI — server-managed consent gate", () => {
|
||||
|
||||
expect(await composeNudgeWithAI(params())).not.toBeNull();
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(latestActiveReceipt).not.toHaveBeenCalled();
|
||||
expect(consentReceiptFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when a server-managed entry sits BEHIND a BYOK primary", async () => {
|
||||
@@ -169,4 +208,25 @@ describe("composeNudgeWithAI — server-managed consent gate", () => {
|
||||
expect(await composeNudgeWithAI(params())).toBeNull();
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the template before resolving any chain when the coach capability is unavailable", async () => {
|
||||
aiCapabilityForJob.mockResolvedValue({
|
||||
available: false,
|
||||
reason: "user_disabled",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
]);
|
||||
const p = params();
|
||||
|
||||
expect(await composeNudgeWithAI(p)).toBeNull();
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "coach");
|
||||
expect(resolveProviderChain).not.toHaveBeenCalled();
|
||||
expect(budgetMocks.reserveBudget).not.toHaveBeenCalled();
|
||||
expect(provider.generateCompletion).not.toHaveBeenCalled();
|
||||
expect(p.tickBudget.remainingCount).toBe(
|
||||
createNudgeAiTickBudget().remainingCount,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
runCoachNudgeTick,
|
||||
} from "../coach-nudge";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { userRowHasProviderCredential } from "@/lib/ai/provider";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
type HealthProfileAiSection,
|
||||
@@ -42,8 +42,10 @@ import {
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
getAssistantFlags: vi.fn(async () => ({ coach: true })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
userRowHasProviderCredential: vi.fn(() => true),
|
||||
// Gate 3 resolves the `coach` capability per user; never the real resolver.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: vi.fn(),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn(() => "morning blood pressure"),
|
||||
@@ -602,9 +604,6 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
user: {
|
||||
findMany: vi.fn(async () => overrides.users ?? []),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(async () => ({ adminAiKeyEncrypted: "k" })),
|
||||
},
|
||||
pushAttempt: {
|
||||
findFirst: vi.fn(async (args?: unknown) => {
|
||||
void args;
|
||||
@@ -661,7 +660,12 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({
|
||||
coach: true,
|
||||
} as Awaited<ReturnType<typeof getAssistantFlags>>);
|
||||
vi.mocked(userRowHasProviderCredential).mockReturnValue(true);
|
||||
vi.mocked(aiCapabilityForJob).mockReset();
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
restModeMock.resolveRestMode.mockResolvedValue({
|
||||
active: false,
|
||||
since: null,
|
||||
@@ -713,7 +717,7 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
{ dispatch: vi.fn() },
|
||||
);
|
||||
expect(summary.skippedOptedOut).toBe(1);
|
||||
expect(userRowHasProviderCredential).not.toHaveBeenCalled();
|
||||
expect(aiCapabilityForJob).not.toHaveBeenCalled();
|
||||
expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -741,15 +745,75 @@ describe("runCoachNudgeTick — gates and prefs", () => {
|
||||
});
|
||||
|
||||
it("checks the provider gate before the frequency cap", async () => {
|
||||
vi.mocked(userRowHasProviderCredential).mockReturnValue(false);
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "no_provider",
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
const prisma = prismaMock({ users: [userRow(null)] });
|
||||
const dispatch = vi.fn();
|
||||
const summary = await runCoachNudgeTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch: vi.fn() },
|
||||
{ dispatch },
|
||||
);
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "coach");
|
||||
expect(summary.skippedNoProvider).toBe(1);
|
||||
expect(summary.skippedUnavailable).toBe(0);
|
||||
expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"operator_disabled",
|
||||
"user_disabled",
|
||||
"not_permitted_for_record",
|
||||
"check_failed",
|
||||
] as const)(
|
||||
"counts an unavailable coach capability (%s) as skippedUnavailable before the cap",
|
||||
async (reason) => {
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const prisma = prismaMock({
|
||||
users: [userRow(null)],
|
||||
intakeRows: failingIntakes,
|
||||
});
|
||||
const dispatch = vi.fn();
|
||||
const summary = await runCoachNudgeTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch },
|
||||
);
|
||||
expect(summary.skippedUnavailable).toBe(1);
|
||||
expect(summary.skippedNoProvider).toBe(0);
|
||||
expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("still sends the template nudge when only a consent receipt is missing", async () => {
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason: "consent_required",
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
const prisma = prismaMock({
|
||||
users: [userRow(null)],
|
||||
intakeRows: failingIntakes,
|
||||
});
|
||||
const dispatch = vi.fn(async () => ({ dispatched: true }));
|
||||
const summary = await runCoachNudgeTick(
|
||||
prisma as unknown as PrismaClient,
|
||||
now,
|
||||
{ dispatch: dispatch as never },
|
||||
);
|
||||
expect(summary.skippedNoProvider).toBe(0);
|
||||
expect(summary.skippedUnavailable).toBe(0);
|
||||
expect(summary.dispatched).toBe(1);
|
||||
});
|
||||
|
||||
it("widens the cap cutoff to 14 days for a biweekly user", async () => {
|
||||
|
||||
@@ -37,6 +37,11 @@ vi.mock("@/lib/jobs/workout-insight-generate-shared", () => ({
|
||||
vi.mock("@/lib/arrivals/reaction-line-shared", () => ({
|
||||
enqueueReactionLine: vi.fn(async () => undefined),
|
||||
}));
|
||||
// The provider-free prefilter the spine asks before enqueueing model work.
|
||||
// Not ruled out by default; the job itself resolves the full capability.
|
||||
vi.mock("@/lib/jobs/ai-job-candidates", () => ({
|
||||
aiWorkNotRuledOut: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
type ExistingReaction = {
|
||||
id: string;
|
||||
@@ -83,6 +88,7 @@ const { enqueueWorkoutInsight } =
|
||||
await import("@/lib/jobs/workout-insight-generate-shared");
|
||||
const { enqueueReactionLine } =
|
||||
await import("@/lib/arrivals/reaction-line-shared");
|
||||
const { aiWorkNotRuledOut } = await import("@/lib/jobs/ai-job-candidates");
|
||||
|
||||
type Runnable = Parameters<typeof runDataArrival>[1];
|
||||
|
||||
@@ -115,6 +121,7 @@ beforeEach(() => {
|
||||
transaction.mockClear();
|
||||
vi.mocked(enqueueReactionLine).mockClear();
|
||||
vi.mocked(enqueueWorkoutInsight).mockClear();
|
||||
vi.mocked(aiWorkNotRuledOut).mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("data-arrival worker", () => {
|
||||
@@ -286,4 +293,42 @@ describe("data-arrival worker", () => {
|
||||
runDataArrival(fakePrisma as never, arrival()),
|
||||
).rejects.toThrow("connection terminated");
|
||||
});
|
||||
|
||||
it("stores the marker but enqueues no workout paragraph when the work is ruled out", async () => {
|
||||
vi.mocked(aiWorkNotRuledOut).mockImplementation(
|
||||
async (_userId, key) => key !== "workoutInsights",
|
||||
);
|
||||
const result = await runDataArrival(
|
||||
fakePrisma as never,
|
||||
arrival({ kind: "workout", refId: "w-1" }),
|
||||
);
|
||||
if (result.status !== "processed") throw new Error("unreachable");
|
||||
expect(aiWorkNotRuledOut).toHaveBeenCalledWith("user-1", "workoutInsights");
|
||||
expect(createMany).toHaveBeenCalledTimes(1);
|
||||
expect(result.actions).toContain("workout_insight_unavailable");
|
||||
expect(result.actions).not.toContain("workout_insight_enqueued");
|
||||
expect(enqueueWorkoutInsight).not.toHaveBeenCalled();
|
||||
// The reaction line is its own capability and still goes out.
|
||||
expect(result.actions).toContain("line_pending");
|
||||
expect(enqueueReactionLine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("claims the marker but enqueues no reaction line when the line is ruled out", async () => {
|
||||
vi.mocked(aiWorkNotRuledOut).mockImplementation(
|
||||
async (_userId, key) => key !== "reactionLines",
|
||||
);
|
||||
const result = await runDataArrival(fakePrisma as never, arrival());
|
||||
if (result.status !== "processed") throw new Error("unreachable");
|
||||
expect(aiWorkNotRuledOut).toHaveBeenCalledWith("user-1", "reactionLines");
|
||||
expect(createMany).toHaveBeenCalledTimes(1);
|
||||
expect(result.actions).toContain("line_unavailable");
|
||||
expect(result.actions).not.toContain("line_pending");
|
||||
expect(enqueueReactionLine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not ask the prefilter for a line when the claim was a no-op", async () => {
|
||||
createMany.mockResolvedValue({ count: 0 });
|
||||
await runDataArrival(fakePrisma as never, arrival());
|
||||
expect(aiWorkNotRuledOut).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Content-index backfill (Document vault P2). Pins: no provider / no consent →
|
||||
* clean no-op; indexes not-yet-indexed docs; stops when the budget is reached
|
||||
* Content-index backfill (Document vault P2). Pins: an operator / module /
|
||||
* personal switch off → refused before the pick; no provider or a pick the
|
||||
* wire re-check withholds (no consent, a switch) → clean no-op; indexes not-yet-indexed docs; stops when the budget is reached
|
||||
* (resumable); skips are bounded by the MIME-filtered candidate set; rows on a
|
||||
* stale `tokenizerVersion` are re-tokenised locally (the column's consumer)
|
||||
* while current-version rows are left alone.
|
||||
@@ -40,9 +41,9 @@ vi.mock("@/lib/documents/content-index", () => ({
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: vi.fn(),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-07"),
|
||||
@@ -60,11 +61,9 @@ import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import {
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
decryptIndexText,
|
||||
upsertContentIndex,
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
|
||||
const PICK = {
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
withheld: null,
|
||||
pick: {
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
providerType: "anthropic",
|
||||
@@ -90,6 +90,11 @@ const doc = (id: string) => ({
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(reserveBudget).mockResolvedValue({
|
||||
allowed: true,
|
||||
reserved: 1,
|
||||
@@ -136,6 +141,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
it("no-ops with no provider", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
withheld: null,
|
||||
pick: null,
|
||||
} as never);
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
@@ -149,21 +155,102 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when consent is missing", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertDocumentEgressConsent).mockRejectedValueOnce(
|
||||
new ConsentRequiredError("insights" as never),
|
||||
);
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
expect(result).toEqual({
|
||||
indexed: 0,
|
||||
retokenised: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
reason: "no-consent",
|
||||
});
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
["consent_required", "no-consent"],
|
||||
["operator_disabled", "unavailable"],
|
||||
["module_disabled", "unavailable"],
|
||||
] as const)(
|
||||
"no-ops when the wire re-check withholds the pick (%s → %s)",
|
||||
async (reason, summaryReason) => {
|
||||
// The pick's own re-check answers for exactly the provider picked: an
|
||||
// external pick without an extraction receipt, or a switch turned off
|
||||
// between the pass-level check and the pick.
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: PICK.chain,
|
||||
pick: null,
|
||||
withheld: new AiUnavailableError("documentAi", reason),
|
||||
} as never);
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "d1" },
|
||||
] as never);
|
||||
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
|
||||
expect(result).toEqual({
|
||||
indexed: 0,
|
||||
retokenised: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
reason: summaryReason,
|
||||
});
|
||||
expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["operator_disabled", "module_disabled", "user_disabled"] as const)(
|
||||
"refuses before the pick and before any document is read when documentAi is switched off (%s)",
|
||||
async (reason) => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "d1" },
|
||||
] as never);
|
||||
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi");
|
||||
expect(result).toEqual({
|
||||
indexed: 0,
|
||||
retokenised: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
reason: "unavailable",
|
||||
});
|
||||
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
|
||||
expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(transcribeDocument).not.toHaveBeenCalled();
|
||||
expect(upsertContentIndex).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["no_provider", "consent_required"] as const)(
|
||||
"leaves %s to the pick: a local pick the presence answer would refuse still indexes",
|
||||
async (reason) => {
|
||||
// Presence may see an external entry without a receipt, or no server
|
||||
// provider at all; the document order can still land on the local
|
||||
// model, which needs neither. The pick decides, not the presence answer.
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "local", instance: {} }],
|
||||
withheld: null,
|
||||
pick: {
|
||||
entry: { providerType: "local", instance: {} },
|
||||
providerType: "local",
|
||||
pdfSupported: false,
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "d1" },
|
||||
] as never);
|
||||
|
||||
const result = await runContentIndexBackfillForUser("user-1");
|
||||
|
||||
expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1");
|
||||
expect(result.reason).toBe("ok");
|
||||
expect(result.indexed).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("indexes the not-yet-indexed documents", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
@@ -192,6 +279,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
// silently leaves every PDF out for exactly those providers.
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "codex", instance: {} }],
|
||||
withheld: null,
|
||||
pick: {
|
||||
entry: { providerType: "codex", instance: {} },
|
||||
providerType: "codex",
|
||||
@@ -234,6 +322,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
it("counts a PDF the rasteriser cannot render as skipped, not failed", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "codex", instance: {} }],
|
||||
withheld: null,
|
||||
pick: {
|
||||
entry: { providerType: "codex", instance: {} },
|
||||
providerType: "codex",
|
||||
@@ -289,6 +378,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
// No provider at all: the sweep is local work and must run anyway.
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
withheld: null,
|
||||
pick: null,
|
||||
} as never);
|
||||
seedIndexRows([
|
||||
@@ -333,6 +423,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
it("touches nothing when every row already carries the current tokenizerVersion", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
withheld: null,
|
||||
pick: null,
|
||||
} as never);
|
||||
seedIndexRows([
|
||||
@@ -358,6 +449,7 @@ describe("runContentIndexBackfillForUser", () => {
|
||||
it("counts an undecryptable stale row as failed and keeps walking", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
withheld: null,
|
||||
pick: null,
|
||||
} as never);
|
||||
seedIndexRows([
|
||||
|
||||
@@ -23,6 +23,10 @@ vi.mock("@/lib/documents/document-settings", () => ({
|
||||
vi.mock("@/lib/jobs/document-summary", () => ({
|
||||
enqueueDocumentSummary: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: vi.fn(),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
enqueueSummaryCatchUp,
|
||||
@@ -35,10 +39,12 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
|
||||
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
|
||||
const findMany = vi.mocked(prisma.inboundDocument.findMany);
|
||||
const mockEnqueueSummary = vi.mocked(enqueueDocumentSummary);
|
||||
const mockAutoRead = vi.mocked(documentAutoReadEnabled);
|
||||
const mockCapability = vi.mocked(aiCapabilityForJob);
|
||||
|
||||
/** Serve `total` document ids across the job's id-cursor paged walk. */
|
||||
function serveDocuments(total: number) {
|
||||
@@ -59,6 +65,11 @@ function serveDocuments(total: number) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAutoRead.mockResolvedValue(true);
|
||||
mockCapability.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
mockEnqueueSummary.mockResolvedValue({ enqueued: true });
|
||||
});
|
||||
|
||||
@@ -111,6 +122,55 @@ describe("runSummaryCatchUpForUser", () => {
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "module_disabled", "user_disabled"] as const)(
|
||||
"does nothing when the documentAi capability is unavailable (%s)",
|
||||
async (reason) => {
|
||||
serveDocuments(5);
|
||||
mockCapability.mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
|
||||
const result = await runSummaryCatchUpForUser("user-1");
|
||||
|
||||
expect(mockCapability).toHaveBeenCalledWith("user-1", "documentAi");
|
||||
expect(result).toEqual({ enqueued: 0, capped: false });
|
||||
// Refused before a single document is listed.
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
expect(mockEnqueueSummary).not.toHaveBeenCalled();
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "documents.autoRead.catchUpSkipped" },
|
||||
meta: { reason },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["no_provider", "consent_required"] as const)(
|
||||
"leaves %s to each job's pick: still walks and enqueues",
|
||||
async (reason) => {
|
||||
// Whether there is a provider and whether sending to it needs a receipt
|
||||
// depends on the document-order pick, which the per-document job
|
||||
// re-checks at the wire. The pass-level presence answer does not decide.
|
||||
serveDocuments(2);
|
||||
mockCapability.mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
|
||||
const result = await runSummaryCatchUpForUser("user-1");
|
||||
|
||||
expect(result).toEqual({ enqueued: 2, capped: false });
|
||||
expect(mockEnqueueSummary).toHaveBeenCalledTimes(2);
|
||||
expect(annotate).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "documents.autoRead.catchUpSkipped" },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("routes work through the ordinary summary job, granting nothing itself", async () => {
|
||||
// The per-document job is what re-asserts egress consent and reserves the
|
||||
// daily budget. The catch-up must not have its own provider or budget path.
|
||||
|
||||
@@ -38,13 +38,6 @@ vi.mock("@/lib/documents/describe", () => ({
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
encryptDocumentSummary: vi.fn(() => new Uint8Array([9, 9, 9])),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => {
|
||||
class ConsentRequiredError extends Error {}
|
||||
return {
|
||||
ConsentRequiredError,
|
||||
assertDocumentEgressConsent: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-17"),
|
||||
reserveBudget: vi.fn(),
|
||||
@@ -53,6 +46,10 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: vi.fn(),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
AI_BUDGETS: { documentSummary: { temperature: 0.3, maxTokens: 600 } },
|
||||
}));
|
||||
@@ -71,8 +68,18 @@ import {
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { runDocumentSummary } from "@/lib/documents/describe";
|
||||
import { encryptDocumentSummary } from "@/lib/documents/store";
|
||||
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
|
||||
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
||||
import {
|
||||
aiCapabilityForJob,
|
||||
aiCapabilityForRecord,
|
||||
} from "@/lib/ai/capabilities/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const AVAILABLE = {
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
} as const;
|
||||
|
||||
const DOC = {
|
||||
id: "doc-1",
|
||||
@@ -91,6 +98,7 @@ const PICK = {
|
||||
providerType: "anthropic",
|
||||
pdfSupported: true,
|
||||
},
|
||||
withheld: null,
|
||||
};
|
||||
|
||||
function visionOk() {
|
||||
@@ -115,8 +123,9 @@ beforeEach(() => {
|
||||
locale: "en",
|
||||
} as never);
|
||||
vi.mocked(documentAutoReadEnabled).mockResolvedValue(true);
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue(AVAILABLE);
|
||||
vi.mocked(aiCapabilityForRecord).mockResolvedValue(AVAILABLE);
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(assertDocumentEgressConsent).mockResolvedValue(undefined);
|
||||
vi.mocked(loadOwnedDocument).mockResolvedValue(DOC as never);
|
||||
visionOk();
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
@@ -150,6 +159,9 @@ describe("runDocumentSummaryJob — gating", () => {
|
||||
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
||||
|
||||
expect(runDocumentSummary).toHaveBeenCalledTimes(1);
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("user-1", "documentAi");
|
||||
// The pick is re-checked at the wire inside the provider order.
|
||||
expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1");
|
||||
expect(encryptDocumentSummary).toHaveBeenCalledWith(
|
||||
"A lab report from a clinic listing routine blood values.",
|
||||
);
|
||||
@@ -203,10 +215,96 @@ describe("runDocumentSummaryJob — gating", () => {
|
||||
expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "module_disabled", "check_failed"] as const)(
|
||||
"refuses before any provider is picked when documentAi is closed (%s → only PENDING heals to NONE)",
|
||||
async (reason) => {
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
|
||||
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
||||
|
||||
expect(resolveDocumentVisionProvider).not.toHaveBeenCalled();
|
||||
expect(loadOwnedDocument).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(runDocumentSummary).not.toHaveBeenCalled();
|
||||
// A switch turned off only heals the PENDING this job was meant to
|
||||
// resolve, so an earlier UNAVAILABLE / WITHHELD is never overwritten.
|
||||
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
summaryState: "PENDING",
|
||||
},
|
||||
data: { summaryState: "NONE" },
|
||||
});
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "documents.summary.autoSkipped" },
|
||||
meta: {
|
||||
documentId: "doc-1",
|
||||
reason: "unavailable",
|
||||
capability_reason: reason,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["no_provider", "consent_required"] as const)(
|
||||
"leaves %s to the pick: the provider order decides",
|
||||
async (reason) => {
|
||||
vi.mocked(aiCapabilityForJob).mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
|
||||
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
||||
|
||||
expect(resolveDocumentVisionProvider).toHaveBeenCalledWith("user-1");
|
||||
expect(runDocumentSummary).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("records UNAVAILABLE with the reason when the pick is withheld at the wire", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: PICK.chain,
|
||||
pick: null,
|
||||
withheld: { reason: "consent_required" },
|
||||
} as never);
|
||||
|
||||
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
||||
|
||||
expect(loadOwnedDocument).not.toHaveBeenCalled();
|
||||
expect(runDocumentSummary).not.toHaveBeenCalled();
|
||||
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { summaryState: "UNAVAILABLE" } }),
|
||||
);
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "documents.summary.autoSkipped" },
|
||||
meta: {
|
||||
documentId: "doc-1",
|
||||
reason: "consent",
|
||||
capability_reason: "consent_required",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not consult the capability when the opt-in is OFF", async () => {
|
||||
vi.mocked(documentAutoReadEnabled).mockResolvedValue(false);
|
||||
|
||||
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
||||
|
||||
expect(aiCapabilityForJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a graceful no-op when NO provider is configured", async () => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
withheld: null,
|
||||
} as never);
|
||||
|
||||
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
||||
@@ -325,6 +423,46 @@ describe("enqueueDocumentSummary — the pending contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "module_disabled", "check_failed"] as const)(
|
||||
"does not enqueue and does not claim PENDING when documentAi is closed (%s)",
|
||||
async (reason) => {
|
||||
vi.mocked(aiCapabilityForRecord).mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const send = vi.fn().mockResolvedValue("job-1");
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
|
||||
const result = await enqueueDocumentSummary("user-1", "doc-1");
|
||||
|
||||
expect(aiCapabilityForRecord).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"documentAi",
|
||||
);
|
||||
expect(result).toEqual({ enqueued: false });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["no_provider", "consent_required"] as const)(
|
||||
"still enqueues on %s: the job's own pick decides",
|
||||
async (reason) => {
|
||||
vi.mocked(aiCapabilityForRecord).mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
const send = vi.fn().mockResolvedValue("job-1");
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
|
||||
const result = await enqueueDocumentSummary("user-1", "doc-1");
|
||||
|
||||
expect(result).toEqual({ enqueued: true });
|
||||
},
|
||||
);
|
||||
|
||||
it("claims nothing when the boss dropped the send (no job id)", async () => {
|
||||
const send = vi.fn().mockResolvedValue(null);
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
* v1.7.0 W6 — unit tests for the nightly insight pre-generation cron.
|
||||
*
|
||||
* Covers:
|
||||
* - the discovery query selects only coach-enabled, stale-cache users;
|
||||
* - the discovery query selects stale-cache users who kept AI analysis
|
||||
* (the `insights` module) on;
|
||||
* - each half of the pass runs on its own capability (`briefing` for the
|
||||
* comprehensive, `statusText` for the warm), resolved before any budget
|
||||
* write or generation;
|
||||
* - the per-user budget gate blocks a user already generated today;
|
||||
* - the master assistant kill-switch short-circuits the whole run;
|
||||
* - the generator outcomes tally correctly (generated / cached /
|
||||
@@ -39,6 +43,29 @@ vi.mock("@/lib/logging/context", async () => {
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a),
|
||||
}));
|
||||
// Per-user capabilities, keyed by capability; every test starts with both
|
||||
// halves available and overrides one or both.
|
||||
type CapabilityState = {
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
onDeviceAllowed: boolean;
|
||||
};
|
||||
const AVAILABLE: CapabilityState = {
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
};
|
||||
function unavailable(reason: string): CapabilityState {
|
||||
return { available: false, reason, onDeviceAllowed: false };
|
||||
}
|
||||
let capabilities: Record<string, CapabilityState> = {};
|
||||
const aiCapabilityForJob = vi.fn(
|
||||
async (_userId: string, key: string) => capabilities[key] ?? AVAILABLE,
|
||||
);
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: (...a: [string, string]) => aiCapabilityForJob(...a),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
// Never reach the real generator (which would import the provider chain).
|
||||
vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
generateComprehensiveInsight: vi.fn(),
|
||||
@@ -109,8 +136,10 @@ function makePrisma(
|
||||
locale: string | null;
|
||||
aiResponseTimeoutSeconds?: number | null;
|
||||
}>,
|
||||
optedOut: string[] = [],
|
||||
) {
|
||||
const findMany = vi.fn().mockResolvedValue(users);
|
||||
const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id })));
|
||||
// forceWarmUser reads `insightsCachedAt` / `insightsWarmFailedAt` at job
|
||||
// start and maintains the failure marker; default: never warmed, never
|
||||
// failed.
|
||||
@@ -120,10 +149,11 @@ function makePrisma(
|
||||
});
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
return {
|
||||
prisma: { user: { findMany, findUnique, update } },
|
||||
prisma: { user: { findMany, findUnique, update }, $queryRaw: queryRaw },
|
||||
findMany,
|
||||
findUnique,
|
||||
update,
|
||||
queryRaw,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,18 +165,26 @@ beforeEach(() => {
|
||||
insightStatus: true,
|
||||
});
|
||||
checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
capabilities = {};
|
||||
});
|
||||
|
||||
describe("findPregenerateCandidates", () => {
|
||||
it("filters on disableCoach=false + stale-or-null cache, oldest-first, capped", async () => {
|
||||
const { prisma, findMany } = makePrisma([{ id: "u1", locale: "de" }]);
|
||||
it("filters on the insights opt-out + stale-or-null cache, oldest-first, capped", async () => {
|
||||
const { prisma, findMany, queryRaw } = makePrisma(
|
||||
[{ id: "u1", locale: "de" }],
|
||||
["opted-out"],
|
||||
);
|
||||
const now = new Date("2026-05-31T04:30:00.000Z");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await findPregenerateCandidates(prisma as any, now, 50);
|
||||
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(queryRaw.mock.calls[0].slice(1)).toContain("insights");
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
const arg = findMany.mock.calls[0][0];
|
||||
expect(arg.where.disableCoach).toBe(false);
|
||||
// Hiding the Coach is not an AI opt-out any more.
|
||||
expect(arg.where.disableCoach).toBeUndefined();
|
||||
expect(arg.where.id).toEqual({ notIn: ["opted-out"] });
|
||||
expect(arg.where.OR).toEqual([
|
||||
{ insightsCachedAt: null },
|
||||
{
|
||||
@@ -158,6 +196,98 @@ describe("findPregenerateCandidates", () => {
|
||||
expect(arg.orderBy).toEqual({ insightsCachedAt: "asc" });
|
||||
expect(arg.take).toBe(50);
|
||||
});
|
||||
|
||||
it("adds no id filter when nobody opted out", async () => {
|
||||
const { prisma, findMany } = makePrisma([{ id: "u1", locale: "de" }]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await findPregenerateCandidates(prisma as any, new Date(), 50);
|
||||
expect(findMany.mock.calls[0][0].where.id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runInsightPregenerate — per-user capabilities", () => {
|
||||
it("skips a user with neither capability before any budget write or generation", async () => {
|
||||
capabilities = {
|
||||
briefing: unavailable("user_disabled"),
|
||||
statusText: unavailable("user_disabled"),
|
||||
};
|
||||
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
|
||||
const generate = vi.fn();
|
||||
const statusGenerators = Array.from({ length: 7 }, () =>
|
||||
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
|
||||
);
|
||||
const warmGenericMetrics = vi.fn().mockResolvedValue(0);
|
||||
|
||||
const result = await runInsightPregenerate(prisma as never, {
|
||||
generate,
|
||||
statusGenerators,
|
||||
warmGenericMetrics,
|
||||
});
|
||||
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "briefing");
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText");
|
||||
expect(result).toMatchObject({ total: 1, skipped: 1, budgetBlocked: 0 });
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
for (const g of statusGenerators) expect(g).not.toHaveBeenCalled();
|
||||
expect(warmGenericMetrics).not.toHaveBeenCalled();
|
||||
expect(annotateSpy).toHaveBeenCalledWith({
|
||||
action: { name: "insights.pregenerate.skipped" },
|
||||
meta: { briefing: "user_disabled", status_text: "user_disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the comprehensive without a budget write when only briefing is unavailable, and still warms", async () => {
|
||||
capabilities = { briefing: unavailable("operator_disabled") };
|
||||
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
|
||||
const generate = vi.fn();
|
||||
const statusGenerators = Array.from({ length: 7 }, () =>
|
||||
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
|
||||
);
|
||||
const warmGenericMetrics = vi.fn().mockResolvedValue(4);
|
||||
|
||||
const result = await runInsightPregenerate(prisma as never, {
|
||||
generate,
|
||||
statusGenerators,
|
||||
warmGenericMetrics,
|
||||
});
|
||||
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.budgetBlocked).toBe(0);
|
||||
for (const g of statusGenerators) {
|
||||
expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: false });
|
||||
}
|
||||
expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de"]);
|
||||
expect(result.assessmentsWarmed).toBe(7);
|
||||
expect(result.metricAssessmentsWarmed).toBe(4);
|
||||
});
|
||||
|
||||
it("generates the comprehensive but warms nothing when only statusText is unavailable", async () => {
|
||||
capabilities = { statusText: unavailable("consent_required") };
|
||||
const { prisma } = makePrisma([{ id: "u1", locale: "de" }]);
|
||||
const generate = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "generated", providerType: "x" });
|
||||
const statusGenerators = Array.from({ length: 7 }, () =>
|
||||
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
|
||||
);
|
||||
const warmGenericMetrics = vi.fn().mockResolvedValue(4);
|
||||
|
||||
const result = await runInsightPregenerate(prisma as never, {
|
||||
generate,
|
||||
statusGenerators,
|
||||
warmGenericMetrics,
|
||||
});
|
||||
|
||||
expect(generate).toHaveBeenCalledTimes(1);
|
||||
expect(result.generated).toBe(1);
|
||||
for (const g of statusGenerators) expect(g).not.toHaveBeenCalled();
|
||||
expect(warmGenericMetrics).not.toHaveBeenCalled();
|
||||
expect(result.assessmentsWarmed).toBe(0);
|
||||
expect(result.metricAssessmentsWarmed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runInsightPregenerate — kill switch", () => {
|
||||
@@ -591,7 +721,10 @@ describe("runInsightPregenerate — per-metric warm pass", () => {
|
||||
expect(result.assessmentsWarmed).toBe(7);
|
||||
});
|
||||
|
||||
it("does NOT warm when the comprehensive pass skipped (no provider)", async () => {
|
||||
it("does NOT warm when the statusText capability has no provider", async () => {
|
||||
// The warm is gated on its own capability now, not on the comprehensive
|
||||
// outcome: no provider for the status notes → nothing to warm.
|
||||
capabilities = { statusText: unavailable("no_provider") };
|
||||
const { prisma } = makePrisma([{ id: "u1", locale: "en" }]);
|
||||
const generate = vi
|
||||
.fn()
|
||||
@@ -714,7 +847,8 @@ describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () =>
|
||||
expect(result.metricAssessmentsWarmed).toBe(9);
|
||||
});
|
||||
|
||||
it("does NOT warm the generic metric caches when the comprehensive pass skipped (no provider)", async () => {
|
||||
it("does NOT warm the generic metric caches when the statusText capability has no provider", async () => {
|
||||
capabilities = { statusText: unavailable("no_provider") };
|
||||
const { prisma } = makePrisma([{ id: "u1", locale: "en" }]);
|
||||
const generate = vi
|
||||
.fn()
|
||||
@@ -1047,13 +1181,12 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("short-circuits to a no-op when the whole assistant is disabled globally", async () => {
|
||||
getAssistantFlags.mockResolvedValue({
|
||||
enabled: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
});
|
||||
const { prisma } = makePrisma([]);
|
||||
it("short-circuits to a no-op when neither capability is available", async () => {
|
||||
capabilities = {
|
||||
briefing: unavailable("operator_disabled"),
|
||||
statusText: unavailable("operator_disabled"),
|
||||
};
|
||||
const { prisma, findUnique } = makePrisma([]);
|
||||
const generate = vi.fn();
|
||||
const statusGenerators = Array.from({ length: 7 }, () =>
|
||||
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
|
||||
@@ -1070,18 +1203,16 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
|
||||
}
|
||||
expect(result.comprehensive).toBe("skipped");
|
||||
expect(result.assessmentsWarmed).toBe(0);
|
||||
// Nothing read, nothing counted against the forced-warm bucket.
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warms the per-status + generic caches when `briefing` is off but `insightStatus` is on (L1)", async () => {
|
||||
// v1.9.0 — the route admits the job on the per-user `insightStatus`
|
||||
// surface. A global `briefing` kill-switch must not suppress the
|
||||
// assessment cards the user has enabled; only the comprehensive briefing
|
||||
// belongs to the `briefing` surface.
|
||||
getAssistantFlags.mockResolvedValue({
|
||||
enabled: true,
|
||||
briefing: false,
|
||||
insightStatus: true,
|
||||
});
|
||||
it("warms the per-status + generic caches when `briefing` is unavailable but `statusText` is available (L1)", async () => {
|
||||
// v1.9.0 — the route admits the job on the per-user status surface. An
|
||||
// unavailable briefing must not suppress the assessment cards; only the
|
||||
// comprehensive briefing belongs to the `briefing` capability.
|
||||
capabilities = { briefing: unavailable("operator_disabled") };
|
||||
const { prisma } = makePrisma([]);
|
||||
const generate = vi.fn();
|
||||
const statusGenerators = Array.from({ length: 7 }, () =>
|
||||
@@ -1109,6 +1240,33 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forces the comprehensive but warms no card when `statusText` is unavailable", async () => {
|
||||
capabilities = { statusText: unavailable("user_disabled") };
|
||||
const { prisma } = makePrisma([]);
|
||||
const generate = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "generated", providerType: "x" });
|
||||
const statusGenerators = Array.from({ length: 7 }, () =>
|
||||
vi.fn().mockResolvedValue({ hasProvider: true, cached: false }),
|
||||
);
|
||||
const warmGenericMetrics = vi.fn().mockResolvedValue(2);
|
||||
|
||||
const result = await forceWarmUser(prisma as never, "u1", "de", {
|
||||
generate,
|
||||
statusGenerators,
|
||||
warmGenericMetrics,
|
||||
});
|
||||
|
||||
expect(generate).toHaveBeenCalledTimes(1);
|
||||
for (const g of statusGenerators) expect(g).not.toHaveBeenCalled();
|
||||
expect(warmGenericMetrics).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
comprehensive: "generated",
|
||||
assessmentsWarmed: 0,
|
||||
metricAssessmentsWarmed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts the comprehensive on timeout so its late resolve cannot write a stale cache row (M-1)", async () => {
|
||||
// v1.9.0 race fix: `withTimeout` cannot cancel the detached generation,
|
||||
// so a comprehensive that resolves AFTER the timeout would still write
|
||||
|
||||
@@ -41,6 +41,17 @@ vi.mock("@/lib/insights/mood-status", () => ({
|
||||
vi.mock("@/lib/insights/medication-compliance-status", () => ({
|
||||
generateMedicationComplianceStatusForUser: vi.fn(),
|
||||
}));
|
||||
// The job resolves the `statusText` capability before any generator runs.
|
||||
const aiCapabilityForJob = vi.fn();
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
const annotate = vi.fn();
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/logging/context")>()),
|
||||
annotate: (...a: unknown[]) => annotate(...a),
|
||||
}));
|
||||
|
||||
import {
|
||||
runInsightStatusGenerate,
|
||||
@@ -51,8 +62,32 @@ import {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
aiCapabilityForJob.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
const OWNER_AUTHORITY = {
|
||||
origin: "owner" as const,
|
||||
recordUserId: "u1",
|
||||
actorUserId: "u1",
|
||||
grantId: null,
|
||||
};
|
||||
|
||||
function allGenerators() {
|
||||
return {
|
||||
general: vi.fn(),
|
||||
"blood-pressure": vi.fn(),
|
||||
weight: vi.fn(),
|
||||
pulse: vi.fn(),
|
||||
bmi: vi.fn(),
|
||||
mood: vi.fn(),
|
||||
"medication-compliance": vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runInsightStatusGenerate", () => {
|
||||
it("forces the matching generator with the payload locale", async () => {
|
||||
const weight = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -82,8 +117,37 @@ describe("runInsightStatusGenerate", () => {
|
||||
expect(weight).toHaveBeenCalledWith("u1", { locale: "en", force: true });
|
||||
// No other generator ran.
|
||||
expect(generators.pulse).not.toHaveBeenCalled();
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "statusText");
|
||||
});
|
||||
|
||||
it.each(["user_disabled", "no_provider", "consent_required"] as const)(
|
||||
"skips before any generator runs when statusText is unavailable (%s)",
|
||||
async (reason) => {
|
||||
aiCapabilityForJob.mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
const generators = allGenerators();
|
||||
await runInsightStatusGenerate(
|
||||
{
|
||||
userId: "u1",
|
||||
metric: "weight",
|
||||
locale: "en",
|
||||
authority: OWNER_AUTHORITY,
|
||||
},
|
||||
generators,
|
||||
);
|
||||
for (const generator of Object.values(generators)) {
|
||||
expect(generator).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "insights.status.generate.skipped" },
|
||||
meta: { metric: "weight", reason },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("skips (does not throw) an unknown metric", async () => {
|
||||
await expect(
|
||||
runInsightStatusGenerate(
|
||||
|
||||
@@ -15,7 +15,15 @@ vi.mock("@/lib/feature-flags", () => ({
|
||||
vi.mock("@/lib/insights/narrative/period-narrative-generate", () => ({
|
||||
generatePeriodNarrative: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
const annotate = vi.fn();
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: (...a: unknown[]) => annotate(...a),
|
||||
}));
|
||||
const aiCapabilityForJob = vi.fn();
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
runPeriodNarrativeWarm,
|
||||
@@ -28,8 +36,10 @@ import {
|
||||
function makePrisma(
|
||||
users: Array<{ id: string; locale: string | null }>,
|
||||
narrativeWrites: Array<{ userId: string; updatedAt: Date }> = [],
|
||||
optedOut: string[] = [],
|
||||
) {
|
||||
const findMany = vi.fn().mockResolvedValue(users);
|
||||
const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id })));
|
||||
const groupBy = vi.fn().mockResolvedValue(
|
||||
narrativeWrites.map((row) => ({
|
||||
userId: row.userId,
|
||||
@@ -37,9 +47,14 @@ function makePrisma(
|
||||
})),
|
||||
);
|
||||
return {
|
||||
prisma: { user: { findMany }, insightNarrative: { groupBy } },
|
||||
prisma: {
|
||||
user: { findMany },
|
||||
insightNarrative: { groupBy },
|
||||
$queryRaw: queryRaw,
|
||||
},
|
||||
findMany,
|
||||
groupBy,
|
||||
queryRaw,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +73,11 @@ beforeEach(() => {
|
||||
insightStatus: true,
|
||||
});
|
||||
checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
aiCapabilityForJob.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("periodsForDay — boundary gate", () => {
|
||||
@@ -74,14 +94,18 @@ describe("periodsForDay — boundary gate", () => {
|
||||
});
|
||||
|
||||
describe("findNarrativeCandidates", () => {
|
||||
it("filters coach-enabled users", async () => {
|
||||
const { prisma, findMany, groupBy } = makePrisma([
|
||||
{ id: "u1", locale: "de" },
|
||||
]);
|
||||
it("drops the users who switched AI analysis off, not the Coach-hidden ones", async () => {
|
||||
const { prisma, findMany, groupBy, queryRaw } = makePrisma(
|
||||
[{ id: "u1", locale: "de" }],
|
||||
[],
|
||||
["opted-out"],
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findNarrativeCandidates(prisma as any, 50);
|
||||
expect(queryRaw.mock.calls[0].slice(1)).toContain("insights");
|
||||
const arg = findMany.mock.calls[0][0];
|
||||
expect(arg.where.disableCoach).toBe(false);
|
||||
expect(arg.where).toEqual({ id: { notIn: ["opted-out"] } });
|
||||
expect(arg.where.disableCoach).toBeUndefined();
|
||||
expect(result).toEqual([{ id: "u1", locale: "de" }]);
|
||||
// Below the cap the ordering aggregate never runs.
|
||||
expect(groupBy).not.toHaveBeenCalled();
|
||||
@@ -156,6 +180,38 @@ describe("runPeriodNarrativeWarm", () => {
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a user whose periodNarrative capability is unavailable before the budget write", async () => {
|
||||
const { prisma } = makePrisma([
|
||||
{ id: "off", locale: "de" },
|
||||
{ id: "on", locale: "de" },
|
||||
]);
|
||||
aiCapabilityForJob.mockImplementation(async (userId: string) =>
|
||||
userId === "off"
|
||||
? { available: false, reason: "no_provider", onDeviceAllowed: true }
|
||||
: { available: true, reason: null, onDeviceAllowed: true },
|
||||
);
|
||||
const generate = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "generated", providerType: "openai" });
|
||||
const result = await runPeriodNarrativeWarm(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma as any,
|
||||
{ now: MON_MID, generate },
|
||||
);
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("off", "periodNarrative");
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.budgetBlocked).toBe(0);
|
||||
// Only the available user reaches the budget bucket and the generator.
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
expect(checkRateLimit.mock.calls[0][0]).toBe("period-narrative:on");
|
||||
expect(generate).toHaveBeenCalledTimes(1);
|
||||
expect(generate.mock.calls[0][0]).toBe("on");
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
action: { name: "insights.narrative.warm.skipped" },
|
||||
meta: { reason: "no_provider" },
|
||||
});
|
||||
});
|
||||
|
||||
it("short-circuits when the briefing surface is disabled", async () => {
|
||||
getAssistantFlags.mockResolvedValueOnce({
|
||||
enabled: false,
|
||||
|
||||
@@ -63,22 +63,21 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const isModuleEnabled = vi.fn();
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
isModuleEnabled: (...a: unknown[]) => isModuleEnabled(...a),
|
||||
const aiCapabilityForJob = vi.fn();
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: (...a: unknown[]) => aiCapabilityForJob(...a),
|
||||
aiCapabilityForRecord: vi.fn(),
|
||||
}));
|
||||
const resolveProviderChain = vi.fn();
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
|
||||
}));
|
||||
|
||||
const chainRequiresServerManagedConsent = vi.fn();
|
||||
const hasActiveConsentForSurface = vi.fn();
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
chainRequiresServerManagedConsent: (...a: unknown[]) =>
|
||||
chainRequiresServerManagedConsent(...a),
|
||||
hasActiveConsentForSurface: (...a: unknown[]) =>
|
||||
hasActiveConsentForSurface(...a),
|
||||
// The wire re-check for the resolved chain. Its rule is pinned in
|
||||
// egress.test.ts; here it passes unless a case refuses.
|
||||
const aiEgressRefusal = vi.fn();
|
||||
vi.mock("@/lib/ai/capabilities/egress", () => ({
|
||||
aiEgressRefusal: (...a: unknown[]) => aiEgressRefusal(...a),
|
||||
}));
|
||||
|
||||
const reserveBudget = vi.fn();
|
||||
@@ -105,6 +104,7 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
}));
|
||||
|
||||
import { runReactionLine } from "@/lib/jobs/reaction-line";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
const JOB = {
|
||||
userId: "u1",
|
||||
@@ -196,9 +196,12 @@ beforeEach(async () => {
|
||||
labResultFindMany.mockResolvedValue([]);
|
||||
queryRaw.mockResolvedValue([{ total_tokens: 1_400, operator_tokens: 1_400 }]);
|
||||
executeRaw.mockResolvedValue(1);
|
||||
isModuleEnabled.mockResolvedValue(true);
|
||||
chainRequiresServerManagedConsent.mockReturnValue(false);
|
||||
hasActiveConsentForSurface.mockResolvedValue(true);
|
||||
aiCapabilityForJob.mockResolvedValue({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
aiEgressRefusal.mockResolvedValue(null);
|
||||
// v1.38.19 — the claim reservation is no longer a
|
||||
// second copy of the upsert; it calls the shared `reserveBudget` with the
|
||||
// transaction client. The spy therefore runs the REAL gate against the
|
||||
@@ -219,18 +222,32 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe("reaction line — degradation", () => {
|
||||
it("insights opt-out refuses before provider resolution or spend", async () => {
|
||||
isModuleEnabled.mockResolvedValue(false);
|
||||
it.each([
|
||||
"user_disabled",
|
||||
"module_disabled",
|
||||
"operator_disabled",
|
||||
"no_provider",
|
||||
"consent_required",
|
||||
] as const)(
|
||||
"an unavailable reactionLines capability (%s) refuses before the digest, provider resolution or spend",
|
||||
async (reason) => {
|
||||
aiCapabilityForJob.mockResolvedValue({
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
|
||||
const outcome = await runReactionLine(JOB);
|
||||
const outcome = await runReactionLine(JOB);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
status: "skipped",
|
||||
reason: "module_disabled",
|
||||
});
|
||||
expect(resolveProviderChain).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "reactionLines");
|
||||
expect(outcome).toEqual({ status: "skipped", reason });
|
||||
expect(userFindUnique).not.toHaveBeenCalled();
|
||||
expect(loadDailyDigest).not.toHaveBeenCalled();
|
||||
expect(resolveProviderChain).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("grounds the prompt in the exact reading that triggered the arrival", async () => {
|
||||
measurementFindMany.mockResolvedValue([
|
||||
@@ -493,14 +510,21 @@ describe("reaction line — degradation", () => {
|
||||
|
||||
it("no consent on a server-managed chain: refuses before reserving", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: {} },
|
||||
{ providerType: "admin-openai", instance: {} },
|
||||
]);
|
||||
chainRequiresServerManagedConsent.mockReturnValue(true);
|
||||
hasActiveConsentForSurface.mockResolvedValue(false);
|
||||
aiEgressRefusal.mockResolvedValue(
|
||||
new AiUnavailableError("reactionLines", "consent_required"),
|
||||
);
|
||||
|
||||
const outcome = await runReactionLine(JOB);
|
||||
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "consent_required" });
|
||||
// Asked about exactly the chain it would cascade through.
|
||||
expect(aiEgressRefusal).toHaveBeenCalledWith("reactionLines", "u1", [
|
||||
"openai",
|
||||
"admin-openai",
|
||||
]);
|
||||
// A user without a receipt must not spend a token OR a ledger slot.
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
*
|
||||
* Pins the three gates the discovery applies:
|
||||
* 1. the operator assistant kill-switch (`insightStatus` flag),
|
||||
* 2. the per-user `disableCoach: false` filter,
|
||||
* 3. the pregenerate-candidate skip (configured provider + stale
|
||||
* comprehensive cache → the 04:30 pass owns the user; the 02:xx
|
||||
* crons keep fresh-cache and provider-less accounts),
|
||||
* 2. the per-user AI-analysis opt-out (the `insights` module switched off
|
||||
* drops the account from the candidate query; `disableCoach` plays no
|
||||
* part any more),
|
||||
* 3. the pregenerate-candidate skip (a stale or missing comprehensive
|
||||
* cache → the 04:30 pass owns the user; the 02:xx crons keep the
|
||||
* fresh-cache accounts), applied only while `briefing` is on,
|
||||
* plus the mood-status queue registration in the worker source (the
|
||||
* v1.4.37 dead-queue class guard).
|
||||
*/
|
||||
@@ -30,15 +32,6 @@ interface FakeUserRow {
|
||||
id: string;
|
||||
locale: string | null;
|
||||
insightsCachedAt: Date | null;
|
||||
aiProvider: string | null;
|
||||
aiProviderChain: unknown;
|
||||
aiAnthropicKeyEncrypted: string | null;
|
||||
aiLocalKeyEncrypted: string | null;
|
||||
aiOpenaiKeyEncrypted: string | null;
|
||||
aiBaseUrl: string | null;
|
||||
codexConnectionStatus: string | null;
|
||||
codexAccessTokenEncrypted: string | null;
|
||||
codexRefreshTokenEncrypted: string | null;
|
||||
}
|
||||
|
||||
function userRow(overrides: Partial<FakeUserRow> = {}): FakeUserRow {
|
||||
@@ -46,32 +39,23 @@ function userRow(overrides: Partial<FakeUserRow> = {}): FakeUserRow {
|
||||
id: "u1",
|
||||
locale: "de",
|
||||
insightsCachedAt: null,
|
||||
aiProvider: null,
|
||||
aiProviderChain: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiBaseUrl: null,
|
||||
codexConnectionStatus: null,
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePrisma(
|
||||
users: FakeUserRow[],
|
||||
adminAiKeyEncrypted: string | null = null,
|
||||
) {
|
||||
function makePrisma(users: FakeUserRow[], optedOut: string[] = []) {
|
||||
const findMany = vi.fn().mockResolvedValue(users);
|
||||
const findUnique = vi.fn().mockResolvedValue({ adminAiKeyEncrypted });
|
||||
const queryRaw = vi.fn().mockResolvedValue(optedOut.map((id) => ({ id })));
|
||||
const findUnique = vi.fn();
|
||||
return {
|
||||
prisma: {
|
||||
user: { findMany },
|
||||
appSettings: { findUnique },
|
||||
$queryRaw: queryRaw,
|
||||
},
|
||||
findMany,
|
||||
findUnique,
|
||||
queryRaw,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,65 +77,82 @@ describe("findStatusCronCandidates — gates", () => {
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
});
|
||||
const { prisma, findMany } = makePrisma([userRow()]);
|
||||
const { prisma, findMany, queryRaw } = makePrisma([userRow()]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(result).toEqual([]);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters discovery to disableCoach: false", async () => {
|
||||
it("does not filter on disableCoach: hiding the Coach is not an AI opt-out", async () => {
|
||||
const { prisma, findMany } = makePrisma([userRow()]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(findMany.mock.calls[0][0].where).toEqual({ disableCoach: false });
|
||||
expect(findMany.mock.calls[0][0].where).toEqual({});
|
||||
expect(JSON.stringify(findMany.mock.calls[0][0])).not.toContain(
|
||||
"disableCoach",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a pregenerate candidate (configured provider + stale cache)", async () => {
|
||||
const { prisma } = makePrisma([
|
||||
userRow({
|
||||
id: "stale-with-provider",
|
||||
insightsCachedAt: STALE_AT,
|
||||
aiAnthropicKeyEncrypted: "enc",
|
||||
}),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a provider user whose comprehensive cache is still fresh", async () => {
|
||||
const { prisma } = makePrisma([
|
||||
userRow({
|
||||
id: "fresh-with-provider",
|
||||
insightsCachedAt: FRESH_AT,
|
||||
aiAnthropicKeyEncrypted: "enc",
|
||||
}),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(result).toEqual([{ id: "fresh-with-provider", locale: "de" }]);
|
||||
});
|
||||
|
||||
it("keeps a provider-less user even with a stale cache (no pregenerate claim)", async () => {
|
||||
const { prisma } = makePrisma([
|
||||
userRow({ id: "no-provider", insightsCachedAt: STALE_AT }),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(result).toEqual([{ id: "no-provider", locale: "de" }]);
|
||||
});
|
||||
|
||||
it("counts the operator's shared admin key as a configured provider", async () => {
|
||||
const { prisma } = makePrisma(
|
||||
[userRow({ id: "admin-covered", insightsCachedAt: STALE_AT })],
|
||||
"admin-enc",
|
||||
it("drops the accounts that switched the insights module off", async () => {
|
||||
const { prisma, findMany, queryRaw } = makePrisma(
|
||||
[userRow({ id: "kept", insightsCachedAt: FRESH_AT })],
|
||||
["opted-out-a", "opted-out-b"],
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||
// The module key rides as a bound parameter, never spliced.
|
||||
expect(queryRaw.mock.calls[0].slice(1)).toContain("insights");
|
||||
expect(findMany.mock.calls[0][0].where).toEqual({
|
||||
id: { notIn: ["opted-out-a", "opted-out-b"] },
|
||||
});
|
||||
expect(result).toEqual([{ id: "kept", locale: "de" }]);
|
||||
});
|
||||
|
||||
it("skips a pregenerate candidate (stale cache)", async () => {
|
||||
const { prisma } = makePrisma([
|
||||
userRow({ id: "stale", insightsCachedAt: STALE_AT }),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a never-warmed user (null cache belongs to pregenerate)", async () => {
|
||||
const { prisma } = makePrisma([
|
||||
userRow({ id: "never", insightsCachedAt: null }),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a user whose comprehensive cache is still fresh", async () => {
|
||||
const { prisma } = makePrisma([
|
||||
userRow({ id: "fresh", insightsCachedAt: FRESH_AT }),
|
||||
userRow({ id: "stale", insightsCachedAt: STALE_AT }),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(result).toEqual([{ id: "fresh", locale: "de" }]);
|
||||
});
|
||||
|
||||
it("reads no provider credentials and no operator admin key", async () => {
|
||||
const { prisma, findMany, findUnique } = makePrisma([
|
||||
userRow({ id: "fresh", insightsCachedAt: FRESH_AT }),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(findMany.mock.calls[0][0].select).toEqual({
|
||||
id: true,
|
||||
locale: true,
|
||||
insightsCachedAt: true,
|
||||
});
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every user when the briefing surface is off (the 04:30 pass no-ops)", async () => {
|
||||
getAssistantFlags.mockResolvedValue({
|
||||
enabled: true,
|
||||
@@ -159,18 +160,16 @@ describe("findStatusCronCandidates — gates", () => {
|
||||
briefing: false,
|
||||
insightStatus: true,
|
||||
});
|
||||
const { prisma, findUnique } = makePrisma([
|
||||
userRow({
|
||||
id: "stale-with-provider",
|
||||
insightsCachedAt: STALE_AT,
|
||||
aiAnthropicKeyEncrypted: "enc",
|
||||
}),
|
||||
const { prisma } = makePrisma([
|
||||
userRow({ id: "stale", insightsCachedAt: STALE_AT }),
|
||||
userRow({ id: "never", insightsCachedAt: null }),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await findStatusCronCandidates(prisma as any, NOW);
|
||||
expect(result).toEqual([{ id: "stale-with-provider", locale: "de" }]);
|
||||
// No admin-key read needed when the skip never applies.
|
||||
expect(findUnique).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([
|
||||
{ id: "stale", locale: "de" },
|
||||
{ id: "never", locale: "de" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
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 },
|
||||
])(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Cheap SQL prefilters for the AI crons.
|
||||
*
|
||||
* Every AI job resolves its capability per user (`aiCapabilityForJob`) before
|
||||
* it builds a snapshot, and the provider chokepoint re-checks it at the wire.
|
||||
* Those are the answers that count. What this file adds is the cheap part in
|
||||
* front of them: people who switched a module off are dropped from the
|
||||
* candidate query itself, so a nightly pass over the instance does not load
|
||||
* their capability inputs one by one only to be told no.
|
||||
*
|
||||
* `modulePreferencesJson` is a DISABLED allowlist: a module is off only when
|
||||
* its key holds JSON `false`. Selecting the "off" set is NULL-safe by
|
||||
* construction (an absent key, a NULL column or a non-object value never
|
||||
* compares equal to `false`), which a negated JSON-path filter would not be.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
type AiCapabilityKey,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import { loadAssistantSwitches } from "@/lib/feature-flags";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
/** Ids of the accounts that switched `module` off for themselves. */
|
||||
export async function userIdsWithModuleOff(
|
||||
prisma: Pick<PrismaClient, "$queryRaw">,
|
||||
module: ModuleKey,
|
||||
): Promise<string[]> {
|
||||
const rows = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT "id" FROM "users"
|
||||
WHERE "module_preferences_json" -> ${module}::text = 'false'::jsonb`;
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider-free half of a capability: the operator's switches and the
|
||||
* owning modules, and nothing that needs the provider machinery.
|
||||
*
|
||||
* For the one place that must not import that machinery at all, the
|
||||
* data-arrival spine, whose cost claim is that its module graph reaches no
|
||||
* provider (`data-arrival-provider-isolation.test.ts`). It decides whether a
|
||||
* follow-up job is worth enqueueing; the job itself then resolves the full
|
||||
* capability (`aiCapabilityForJob`) before it does anything, so this can only
|
||||
* ever say "no" early, never "yes" for good.
|
||||
*/
|
||||
export async function aiWorkNotRuledOut(
|
||||
userId: string,
|
||||
key: AiCapabilityKey,
|
||||
): Promise<boolean> {
|
||||
const definition = AI_CAPABILITIES[key];
|
||||
const [switches, modules] = await Promise.all([
|
||||
loadAssistantSwitches(),
|
||||
resolveModuleMap(userId),
|
||||
]);
|
||||
if (switches === null || !switches[definition.operatorSwitch]) return false;
|
||||
const owners = definition.modules.keys;
|
||||
if (owners.length === 0) return true;
|
||||
return definition.modules.mode === "all"
|
||||
? owners.every((module) => modules[module])
|
||||
: owners.some((module) => modules[module]);
|
||||
}
|
||||
@@ -35,10 +35,8 @@
|
||||
import type { CoachNudgeTrigger } from "@/lib/jobs/coach-nudge";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveProviderChain } from "@/lib/ai/provider";
|
||||
import {
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -173,19 +171,38 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// The capability at the wire, before the chain is resolved (a Codex
|
||||
// chain may refresh a token on resolve). The cron checked it for the
|
||||
// candidate already; a switch can flip within the tick.
|
||||
const coach = await aiCapabilityForJob(params.userId, "coach");
|
||||
if (!coach.available) {
|
||||
annotate({
|
||||
action: { name: "coach.nudge.ai.unavailable" },
|
||||
meta: { reason: coach.reason },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const chain = await resolveProviderChain(params.userId);
|
||||
if (chain.length === 0) return null;
|
||||
|
||||
// Consent gate — before the budget reservation, so a user without a
|
||||
// receipt never spends a slot or a token. Skip-shaped like every other
|
||||
// guard here: no receipt → return null and the caller ships the
|
||||
// deterministic template, so the nudge itself is never lost. BYOK / local
|
||||
// / ChatGPT-OAuth chains are the user's own egress and stay ungated.
|
||||
if (
|
||||
chainRequiresServerManagedConsent(chain) &&
|
||||
!(await hasActiveConsentForSurface(params.userId, "coach"))
|
||||
) {
|
||||
annotate({ action: { name: "coach.nudge.ai.consent_required" } });
|
||||
// The wire re-check for exactly this chain, before the budget
|
||||
// reservation so a user without a receipt never spends a slot or a token:
|
||||
// the capability again, and the consent an operator-held entry needs.
|
||||
// Skip-shaped like every other guard here: a refusal returns null and the
|
||||
// caller ships the deterministic template, so the nudge itself is never
|
||||
// lost. A person's own key, their ChatGPT account or a local model need no
|
||||
// receipt.
|
||||
const refusal = await aiEgressRefusal(
|
||||
"coach",
|
||||
params.userId,
|
||||
chain.map((entry) => entry.providerType),
|
||||
);
|
||||
if (refusal) {
|
||||
annotate({
|
||||
action: { name: "coach.nudge.ai.refused" },
|
||||
meta: { reason: refusal.reason },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+28
-27
@@ -13,9 +13,14 @@
|
||||
* 1. Operator kill-switch via `getAssistantFlags().coach` — the
|
||||
* master assistant switch forces this off too.
|
||||
* 2. Per-user `disableCoach: false` — no Coach surface, no nudge.
|
||||
* 3. A working provider (`userRowHasProviderCredential`, including
|
||||
* the operator's shared key) — a nudge into a Coach that cannot
|
||||
* answer is worse than silence.
|
||||
* 3. The `coach` capability for the user (`aiCapabilityForJob`): the
|
||||
* operator's switches, the person's Coach switch, provider-work
|
||||
* authority and one provider-presence definition (the operator's
|
||||
* shared key and the central Codex included). A nudge into a Coach
|
||||
* that cannot answer is worse than silence. The one reason that does
|
||||
* not stop the nudge is a missing consent receipt: the deterministic
|
||||
* template needs no model, and the Coach it invites the user into asks
|
||||
* for consent itself.
|
||||
* 4. Per-user opt-out: `notificationPrefs.coach.nudgesEnabled`
|
||||
* (default ON; Settings → Notifications). v1.16.5 adds per-group
|
||||
* toggles underneath (medication / vitals / routine) — a disabled
|
||||
@@ -78,7 +83,8 @@
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { userRowHasProviderCredential } from "@/lib/ai/provider";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
@@ -201,6 +207,12 @@ export interface CoachNudgeSummary {
|
||||
persisted: number;
|
||||
skippedOptedOut: number;
|
||||
skippedNoProvider: number;
|
||||
/**
|
||||
* The `coach` capability is unavailable for a reason other than a missing
|
||||
* provider or consent (operator switch, Coach switch, authority, a failed
|
||||
* check).
|
||||
*/
|
||||
skippedUnavailable: number;
|
||||
skippedRecentNudge: number;
|
||||
/**
|
||||
* v1.25.0 — anti-nag: the user talked to the Coach within the last ~24 h,
|
||||
@@ -806,6 +818,7 @@ export async function runCoachNudgeTick(
|
||||
persisted: 0,
|
||||
skippedOptedOut: 0,
|
||||
skippedNoProvider: 0,
|
||||
skippedUnavailable: 0,
|
||||
skippedRecentNudge: 0,
|
||||
skippedRecentEngagement: 0,
|
||||
skippedNoTrigger: 0,
|
||||
@@ -818,8 +831,7 @@ export async function runCoachNudgeTick(
|
||||
const flags = await getAssistantFlags();
|
||||
if (!flags.coach) return summary;
|
||||
|
||||
// Gate 2 — per-user Coach opt-out, plus the credential-presence
|
||||
// columns gate 3 needs (evaluated locally, never decrypted).
|
||||
// Gate 2 — per-user Coach opt-out.
|
||||
const users = await prisma.user.findMany({
|
||||
where: { disableCoach: false },
|
||||
select: {
|
||||
@@ -834,28 +846,10 @@ export async function runCoachNudgeTick(
|
||||
gender: true,
|
||||
thresholdsJson: true,
|
||||
timezone: true,
|
||||
aiProvider: true,
|
||||
aiProviderChain: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
aiBaseUrl: true,
|
||||
aiModel: true,
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatModel: true,
|
||||
codexConnectionStatus: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (users.length === 0) return summary;
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { adminAiKeyEncrypted: true },
|
||||
});
|
||||
const adminKeyConfigured = !!settings?.adminAiKeyEncrypted;
|
||||
|
||||
for (const user of users) {
|
||||
summary.candidatesScanned += 1;
|
||||
try {
|
||||
@@ -873,9 +867,16 @@ export async function runCoachNudgeTick(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gate 3 — a Coach without a provider cannot answer the nudge.
|
||||
if (!userRowHasProviderCredential(user, adminKeyConfigured)) {
|
||||
summary.skippedNoProvider += 1;
|
||||
// Gate 3 — the `coach` capability. A Coach that cannot answer gets no
|
||||
// nudge; a missing consent receipt alone still gets the template.
|
||||
const coach = await aiCapabilityForJob(user.id, "coach");
|
||||
if (!coach.available && coach.reason !== "consent_required") {
|
||||
if (coach.reason === "no_provider") summary.skippedNoProvider += 1;
|
||||
else summary.skippedUnavailable += 1;
|
||||
annotate({
|
||||
action: { name: "coach.nudge.skipped" },
|
||||
meta: { reason: coach.reason },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { Job } from "pg-boss";
|
||||
import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiWorkNotRuledOut } from "@/lib/jobs/ai-job-candidates";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
|
||||
import { DATA_ARRIVAL_QUEUE } from "@/lib/arrivals/emit-shared";
|
||||
@@ -212,7 +213,19 @@ export async function runDataArrival(
|
||||
// once-per-workout key, duration floor, hard daily cap, input hash and
|
||||
// token-ledger reservation bound the spend — none of which belong in the
|
||||
// spine, which is why only the generator-free enqueue is imported here.
|
||||
if (arrival.refId) {
|
||||
if (!arrival.refId) {
|
||||
// A workout arrival with no referent cannot address a paragraph. The
|
||||
// seams always carry one; annotating rather than guessing keeps a
|
||||
// future seam that forgets it visible.
|
||||
actions.push("workout_no_ref");
|
||||
} else if (
|
||||
!(await aiWorkNotRuledOut(arrival.userId, "workoutInsights"))
|
||||
) {
|
||||
// Nothing is enqueued that a switch or a module already rules out.
|
||||
// The workout itself is data and already stored; only the paragraph
|
||||
// about it is withheld. The job resolves the full capability itself.
|
||||
actions.push("workout_insight_unavailable");
|
||||
} else {
|
||||
const insight = await enqueueWorkoutInsight({
|
||||
userId: arrival.userId,
|
||||
workoutId: arrival.refId,
|
||||
@@ -221,11 +234,6 @@ export async function runDataArrival(
|
||||
throw new Error("Workout insight enqueue failed");
|
||||
}
|
||||
actions.push("workout_insight_enqueued");
|
||||
} else {
|
||||
// A workout arrival with no referent cannot address a paragraph. The
|
||||
// seams always carry one; annotating rather than guessing keeps a
|
||||
// future seam that forgets it visible.
|
||||
actions.push("workout_no_ref");
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -237,7 +245,15 @@ export async function runDataArrival(
|
||||
break;
|
||||
}
|
||||
|
||||
if (reactionClaim !== "unchanged") {
|
||||
if (
|
||||
reactionClaim !== "unchanged" &&
|
||||
!(await aiWorkNotRuledOut(arrival.userId, "reactionLines"))
|
||||
) {
|
||||
// The marker is the feature and is written above; the line is garnish a
|
||||
// model writes, and a switch or a module already rules it out. The job
|
||||
// resolves the full capability itself.
|
||||
actions.push("line_unavailable");
|
||||
} else if (reactionClaim !== "unchanged") {
|
||||
// A fresh marker and a strictly newer replacement both need a line. The
|
||||
// replacement transaction cleared the old generation owner first, and the
|
||||
// enqueue key includes this arrival revision so an older in-flight job
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
* indexing egresses to a provider under the user's key + budget, so it must be
|
||||
* a deliberate, consented act, exactly like the per-document index route.
|
||||
*
|
||||
* Consent: gated on the document-egress consent (`assertDocumentEgressConsent`),
|
||||
* the same gate the extract / index routes use — any external provider needs an
|
||||
* active receipt; a local pick stays ungated. There is no separate per-user
|
||||
* Consent: gated on the `documentAi` capability and the wire re-check of the
|
||||
* document pick (`resolveDocumentVisionProvider` → `aiEgressRefusal`), the same
|
||||
* gate the extract / index routes use — any external provider needs an active
|
||||
* extraction receipt; a local pick needs none. There is no separate per-user
|
||||
* toggle (maintainer decision, 2026-07-07).
|
||||
*
|
||||
* Bounded + resumable: an id-cursor forward walk over the not-yet-indexed set,
|
||||
@@ -30,10 +31,8 @@
|
||||
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
|
||||
*/
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import {
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -96,7 +95,8 @@ export interface ContentIndexBackfillSummary {
|
||||
* reservation existed; a later run retries.
|
||||
*/
|
||||
failed: number;
|
||||
reason: "ok" | "no-provider" | "no-consent" | "budget-reached";
|
||||
reason:
|
||||
"ok" | "no-provider" | "no-consent" | "budget-reached" | "unavailable";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,32 +164,45 @@ export async function runContentIndexBackfillForUser(
|
||||
// provider still gets their existing index moved to the current rules.
|
||||
const stale = await retokeniseStaleRows(userId);
|
||||
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick)
|
||||
// The `documentAi` capability before any document is listed: the
|
||||
// operator's switches (the master and "Reading documents") and the vault
|
||||
// module. Whether there is a provider and whether sending to it needs a
|
||||
// receipt is the pick's to answer, just below.
|
||||
const capability = await aiCapabilityForJob(userId, "documentAi");
|
||||
if (
|
||||
capability.reason !== null &&
|
||||
!PICK_DECIDED_REASONS.has(capability.reason)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "documents.contentIndex.backfillSkipped" },
|
||||
meta: { reason: capability.reason },
|
||||
});
|
||||
return {
|
||||
indexed: 0,
|
||||
retokenised: stale.retokenised,
|
||||
skipped: 0,
|
||||
failed: stale.failed,
|
||||
reason: "no-provider",
|
||||
reason: "unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
// The document-order pick, re-checked at the wire for exactly that
|
||||
// provider: the capability again, and the extraction receipt an external
|
||||
// pick needs (`ai_extraction` or `ai_full`). A local pick needs none.
|
||||
const { pick, withheld } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return {
|
||||
indexed: 0,
|
||||
retokenised: stale.retokenised,
|
||||
skipped: 0,
|
||||
failed: stale.failed,
|
||||
reason:
|
||||
withheld === null
|
||||
? "no-provider"
|
||||
: withheld.reason === "consent_required"
|
||||
? "no-consent"
|
||||
: "unavailable",
|
||||
};
|
||||
try {
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ConsentRequiredError) {
|
||||
return {
|
||||
indexed: 0,
|
||||
retokenised: stale.retokenised,
|
||||
skipped: 0,
|
||||
failed: stale.failed,
|
||||
reason: "no-consent",
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// PDFs are always candidates: `prepareVisionInput` passes them natively
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
|
||||
*/
|
||||
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
|
||||
@@ -92,6 +94,22 @@ export async function runSummaryCatchUpForUser(
|
||||
return { enqueued: 0, capped: false };
|
||||
}
|
||||
|
||||
// The `documentAi` capability, once for the whole pass, before any document
|
||||
// is listed: a switch or the vault module off means there is nothing the
|
||||
// per-document jobs could do. A missing provider or receipt is each job's
|
||||
// pick to decide.
|
||||
const capability = await aiCapabilityForJob(userId, "documentAi");
|
||||
if (
|
||||
capability.reason !== null &&
|
||||
!PICK_DECIDED_REASONS.has(capability.reason)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "documents.autoRead.catchUpSkipped" },
|
||||
meta: { reason: capability.reason },
|
||||
});
|
||||
return { enqueued: 0, capped: false };
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let capped = false;
|
||||
let cursor: string | null = null;
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
*/
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import {
|
||||
assertDocumentEgressConsent,
|
||||
ConsentRequiredError,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
aiCapabilityForJob,
|
||||
aiCapabilityForRecord,
|
||||
} from "@/lib/ai/capabilities/gate";
|
||||
import { PICK_DECIDED_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
@@ -135,37 +136,59 @@ export async function runDocumentSummaryJob(
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the DOCUMENT-order vision provider (local-first, codex last). No
|
||||
// vision-capable provider configured → graceful no-op (unlike the index job,
|
||||
// there is no local text-layer fallback for a descriptive summary).
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
await markSummaryState(userId, documentId, "UNAVAILABLE");
|
||||
// The `documentAi` capability before a provider is picked or the document is
|
||||
// decrypted: the operator's switches (the master and "Reading documents")
|
||||
// and the vault module. A switch turned off means this job was never to run
|
||||
// at all, so only the PENDING it was meant to resolve goes back to NONE; a
|
||||
// recorded UNAVAILABLE or WITHHELD is an earlier real attempt and stays.
|
||||
// Whether there is a provider and whether sending to it needs a receipt is
|
||||
// the pick's to answer (the document order differs from the chain order the
|
||||
// presence probe reads), so those two reasons wait for the pick below.
|
||||
const capability = await aiCapabilityForJob(userId, "documentAi");
|
||||
if (
|
||||
capability.reason !== null &&
|
||||
!PICK_DECIDED_REASONS.has(capability.reason)
|
||||
) {
|
||||
await prisma.inboundDocument.updateMany({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
summaryState: "PENDING",
|
||||
},
|
||||
data: { summaryState: "NONE" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "documents.summary.autoSkipped" },
|
||||
meta: { documentId, reason: "no-provider" },
|
||||
meta: {
|
||||
documentId,
|
||||
reason: "unavailable",
|
||||
capability_reason: capability.reason,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-assert egress consent for the picked provider. A local pick is ungated;
|
||||
// an external pick is authorised by the `documentsAutoAiRead` opt-in checked
|
||||
// above (the toggle short-circuits the gate). Belt-and-braces: a consent race
|
||||
// (opt-out flipped mid-flight) resolves to a no-op, never an egress.
|
||||
try {
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
} catch (err) {
|
||||
// Resolve the DOCUMENT-order vision provider (local-first, codex last) and
|
||||
// re-check the wire for exactly that pick: the capability again and the
|
||||
// extraction receipt an external pick needs (`ai_extraction` or `ai_full`;
|
||||
// the auto-read toggle mints one but is not one, so a revoke mid-flight wins).
|
||||
// No pick → an attempt that could not run, recorded as UNAVAILABLE (unlike
|
||||
// the index job, there is no local text-layer fallback for a summary).
|
||||
const { pick, withheld } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
await markSummaryState(userId, documentId, "UNAVAILABLE");
|
||||
annotate({
|
||||
action: { name: "documents.summary.autoSkipped" },
|
||||
meta: {
|
||||
documentId,
|
||||
reason:
|
||||
err instanceof ConsentRequiredError ? "consent" : "consent-error",
|
||||
withheld === null
|
||||
? "no-provider"
|
||||
: withheld.reason === "consent_required"
|
||||
? "consent"
|
||||
: "unavailable",
|
||||
capability_reason: withheld?.reason ?? null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -319,6 +342,21 @@ export async function enqueueDocumentSummary(
|
||||
});
|
||||
return { enqueued: false };
|
||||
}
|
||||
// Nothing is queued that the capability already refuses (a switch or the
|
||||
// vault module off): the document stays NONE and the view offers the
|
||||
// manual action, exactly as with the opt-in off. A missing provider or
|
||||
// receipt is the job's pick to decide.
|
||||
const capability = await aiCapabilityForRecord(userId, "documentAi");
|
||||
if (
|
||||
capability.reason !== null &&
|
||||
!PICK_DECIDED_REASONS.has(capability.reason)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "documents.summary.autoSkipped" },
|
||||
meta: { documentId, reason: capability.reason },
|
||||
});
|
||||
return { enqueued: false };
|
||||
}
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return { enqueued: false };
|
||||
const payload: DocumentSummaryPayload = {
|
||||
|
||||
@@ -54,6 +54,8 @@ import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { userIdsWithModuleOff } from "@/lib/jobs/ai-job-candidates";
|
||||
import type { SupportedLocale } from "@/lib/insights/status-shared";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import {
|
||||
@@ -504,10 +506,11 @@ interface PregenerateCandidate {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery query — coach-enabled users with a stale or missing
|
||||
* comprehensive-insight cache, oldest-cache-first so the staleest users
|
||||
* are served before the per-run cap bites. Exported so the cron test
|
||||
* can pin the WHERE shape without a live LLM.
|
||||
* Discovery query — users with AI analysis on (the `insights` module) and a
|
||||
* stale or missing comprehensive-insight cache, oldest-cache-first so the
|
||||
* staleest users are served before the per-run cap bites. Hiding the Coach
|
||||
* no longer excludes anyone: it hides the Coach and nothing else. Exported
|
||||
* so the cron test can pin the WHERE shape without a live LLM.
|
||||
*/
|
||||
export async function findPregenerateCandidates(
|
||||
prisma: PrismaClient,
|
||||
@@ -515,9 +518,10 @@ export async function findPregenerateCandidates(
|
||||
cap: number,
|
||||
): Promise<PregenerateCandidate[]> {
|
||||
const staleBefore = new Date(now.getTime() - PREGENERATE_STALE_MS);
|
||||
const optedOut = await userIdsWithModuleOff(prisma, "insights");
|
||||
return prisma.user.findMany({
|
||||
where: {
|
||||
disableCoach: false,
|
||||
...(optedOut.length > 0 ? { id: { notIn: optedOut } } : {}),
|
||||
OR: [
|
||||
{ insightsCachedAt: null },
|
||||
{ insightsCachedAt: { lt: staleBefore } },
|
||||
@@ -620,6 +624,26 @@ export async function runInsightPregenerate(
|
||||
// sibling consumer in this candidate's warm reuses it. Scoped per user so
|
||||
// no cross-user object can leak.
|
||||
await withFeatureCacheScope(async () => {
|
||||
// The two capabilities this pass serves, resolved before any snapshot
|
||||
// or rate-limit write: the briefing for the comprehensive insight, the
|
||||
// status notes for the per-metric warm. Each half runs only when its
|
||||
// own capability is available; the chokepoints re-check at the wire.
|
||||
const [briefing, statusText] = await Promise.all([
|
||||
aiCapabilityForJob(candidate.id, "briefing"),
|
||||
aiCapabilityForJob(candidate.id, "statusText"),
|
||||
]);
|
||||
if (!briefing.available && !statusText.available) {
|
||||
result.skipped++;
|
||||
annotate({
|
||||
action: { name: "insights.pregenerate.skipped" },
|
||||
meta: {
|
||||
briefing: briefing.reason,
|
||||
status_text: statusText.reason,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Budget gate — one COMPREHENSIVE pre-generation per user per 20 h.
|
||||
// The route's on-demand path uses a different bucket
|
||||
// (`insights:${userId}`), so this never starves a user's manual
|
||||
@@ -628,18 +652,25 @@ export async function runInsightPregenerate(
|
||||
// the 02:xx status crons already skipped every pregenerate candidate
|
||||
// on the assumption that THIS pass warms their cards — exiting early
|
||||
// here left those cards cold until the first on-visit generation.
|
||||
const budget = await checkRateLimit(
|
||||
`insight-pregenerate:${candidate.id}`,
|
||||
1,
|
||||
PREGENERATE_BUDGET_WINDOW_MS,
|
||||
);
|
||||
const budget = briefing.available
|
||||
? await checkRateLimit(
|
||||
`insight-pregenerate:${candidate.id}`,
|
||||
1,
|
||||
PREGENERATE_BUDGET_WINDOW_MS,
|
||||
)
|
||||
: null;
|
||||
|
||||
// The stored locale, then the operator default: a NULL column used to
|
||||
// resolve to English here, so a German-language instance warmed its
|
||||
// briefings in English every night.
|
||||
const locale = await resolveJobLocale(candidate.locale);
|
||||
let outcome: GenerateOutcome | null = null;
|
||||
if (!budget.allowed) {
|
||||
if (budget === null) {
|
||||
result.skipped++;
|
||||
annotate({
|
||||
action: { name: "insights.pregenerate.skipped" },
|
||||
meta: { briefing: briefing.reason },
|
||||
});
|
||||
} else if (!budget.allowed) {
|
||||
result.budgetBlocked++;
|
||||
} else {
|
||||
// Force a fresh generation: the discovery window (20 h) is shorter
|
||||
@@ -709,7 +740,7 @@ export async function runInsightPregenerate(
|
||||
// before the morning visit instead of waiting for the next night.
|
||||
await enqueueRetry({ userId: candidate.id, locale });
|
||||
} else {
|
||||
outcome = bounded.value;
|
||||
const outcome = bounded.value;
|
||||
switch (outcome.status) {
|
||||
case "generated":
|
||||
result.generated++;
|
||||
@@ -758,11 +789,12 @@ export async function runInsightPregenerate(
|
||||
// consent-skipped included) because the 02:xx status crons skip every
|
||||
// pregenerate candidate on the assumption that THIS pass covers them.
|
||||
//
|
||||
// The only outcome with nothing to warm is a missing provider
|
||||
// (`skipped`/`no-provider`): the per-card generators would no-op to
|
||||
// their fallbacks anyway, so the pass is skipped to save the
|
||||
// chain-resolves.
|
||||
if (outcome?.status === "skipped" && outcome.reason === "no-provider") {
|
||||
// Nothing to warm when the status notes themselves are unavailable.
|
||||
if (!statusText.available) {
|
||||
annotate({
|
||||
action: { name: "insights.pregenerate.status_skipped" },
|
||||
meta: { reason: statusText.reason },
|
||||
});
|
||||
return;
|
||||
}
|
||||
result.assessmentsWarmed += await warmStatus(candidate.id, [locale]);
|
||||
@@ -881,17 +913,22 @@ export async function forceWarmUser(
|
||||
metricAssessmentsWarmed: 0,
|
||||
};
|
||||
|
||||
// v1.9.0 — gate each section on the flag for the surface it actually
|
||||
// warms, matching the route. The HTTP route admits this job on the per-user
|
||||
// `insightStatus` surface, so warming the per-status + generic-metric cards
|
||||
// must run whenever `insightStatus` is enabled. Only the comprehensive
|
||||
// briefing belongs to the `briefing` surface — gating the whole warm on
|
||||
// `briefing` (the previous behaviour) let an operator's global `briefing`
|
||||
// kill-switch silently suppress the assessment cards the user has enabled.
|
||||
// When the master assistant switch is off both flags resolve false, so the
|
||||
// whole thing still no-ops.
|
||||
const flags = await getAssistantFlags();
|
||||
if (!flags.briefing && !flags.insightStatus) return result;
|
||||
// Each section runs on its own capability, resolved for this user under
|
||||
// the job's authority: the comprehensive briefing on `briefing`, the
|
||||
// per-status and generic-metric notes on `statusText`. One unavailable
|
||||
// half never suppresses the other; both unavailable makes the job a
|
||||
// no-op before any snapshot is built.
|
||||
const [briefing, statusText] = await Promise.all([
|
||||
aiCapabilityForJob(userId, "briefing"),
|
||||
aiCapabilityForJob(userId, "statusText"),
|
||||
]);
|
||||
if (!briefing.available && !statusText.available) {
|
||||
annotate({
|
||||
action: { name: "insights.pregenerate.force.skipped" },
|
||||
meta: { briefing: briefing.reason, status_text: statusText.reason },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// v1.16.8 — warm only the caller's resolved locale. The old dual-locale
|
||||
// warm compensated for the comprehensive write's cross-locale per-status
|
||||
@@ -926,7 +963,7 @@ export async function forceWarmUser(
|
||||
// 3. daily cap — at most FORCE_WARM_DAILY_LIMIT forced attempts per
|
||||
// rolling 24 h, so even a client bug that defeats 1+2 cannot turn
|
||||
// the forced path into an unmetered generation loop.
|
||||
if (flags.briefing) {
|
||||
if (briefing.available) {
|
||||
let freshness: {
|
||||
insightsCachedAt: Date | null;
|
||||
insightsWarmFailedAt: Date | null;
|
||||
@@ -1066,15 +1103,15 @@ export async function forceWarmUser(
|
||||
}
|
||||
}
|
||||
|
||||
// Run the per-status + generic-metric warm passes whenever the
|
||||
// `insightStatus` surface is enabled, regardless of the comprehensive
|
||||
// Run the per-status + generic-metric warm passes whenever `statusText`
|
||||
// is available, regardless of the comprehensive
|
||||
// outcome. Each generator independently no-ops to its no-key fallback when
|
||||
// no provider is configured (no LLM call) and serves its fallback without
|
||||
// persisting on a per-card timeout, so running them after a failed
|
||||
// comprehensive is safe and bounded by their own per-card budgets. The only
|
||||
// outcome that has nothing to warm is a missing provider, and the
|
||||
// generators detect that themselves at near-zero cost.
|
||||
if (flags.insightStatus) {
|
||||
if (statusText.available) {
|
||||
// Refill-only (v1.16.8): a card already generated today is a cheap
|
||||
// cache read; a cold card runs its generator, whose content-hash gate
|
||||
// skips the LLM when the underlying data did not change. There is no
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
type InsightStatusMetric,
|
||||
type InsightStatusGeneratePayload,
|
||||
} from "@/lib/jobs/insight-status-generate-shared";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
mayDispatchProviderWork,
|
||||
withProviderWorkAuthority,
|
||||
@@ -115,6 +116,19 @@ async function dispatchInsightStatusGenerate(
|
||||
payload: InsightStatusGeneratePayload,
|
||||
generators: Record<InsightStatusMetric, StatusGenerator> = GENERATORS,
|
||||
): Promise<void> {
|
||||
// The capability, under the job's own authority, before any generator
|
||||
// builds a snapshot. The route that enqueued this checked it too, but a
|
||||
// switch, the AI analysis opt-out or a consent can change while the job
|
||||
// waits in the queue.
|
||||
const capability = await aiCapabilityForJob(payload.userId, "statusText");
|
||||
if (!capability.available) {
|
||||
annotate({
|
||||
action: { name: "insights.status.generate.skipped" },
|
||||
meta: { metric: payload.metric, reason: capability.reason },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// v1.8.7.1 — a `metric:<METRIC_ID>` scope routes to the generic
|
||||
// HealthKit-metric generator rather than one of the seven specialised
|
||||
// ones. The generic generator applies its own empty-data guard, so a
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user