mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge release/v1.39.0 into feat/ai-routes-insights-coach
Brings in the jobs, stored-text and consent package and the extraction routes. Conflicts resolved: - The digest keeps its capability-driven reaction line and check-in, now through the shared `aiCapabilityForRecord`; the local copy of it is removed. - The route inventories classify the extraction routes (documents, labs OCR, medication text, fenced chat and attachments) as actions; a capability call counts as the gate for the module inventory. - With every caller moved, `requireAssistantSurface` and `AssistantDisabledError` are deleted. The `assistant.disabled.*` codes stay on the capability refusal, and the guard now asserts the retired gate is gone. - `aiCapabilityToServe` answers whether stored model text may be shown from the record's own state, apart from who is reading. Refs #975
This commit is contained in:
commit
c509afeb2e
256 files changed
+13048
-4642
No files matched your search
@@ -64,14 +64,14 @@ const RESOLUTION_HELPERS = [
|
||||
"resolveProviderForTest",
|
||||
] as const;
|
||||
|
||||
/** The two sanctioned consent APIs. Either satisfies the guard. */
|
||||
/** The sanctioned consent APIs in the consent guard. Any satisfies the guard. */
|
||||
const CONSENT_HELPERS = [
|
||||
// Chain-shaped: throw-form and predicate-form.
|
||||
"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;
|
||||
|
||||
@@ -108,9 +108,23 @@ function resolvesAProvider(source: string): boolean {
|
||||
return RESOLUTION_HELPERS.some((h) => imported.includes(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* The capability re-check at the wire. It answers the consent question for the
|
||||
* providers a caller is about to send to, from the receipt kinds the capability
|
||||
* table declares, so it is a consent helper in its own right.
|
||||
*/
|
||||
const CAPABILITY_EGRESS_HELPERS = [
|
||||
"assertAiEgress",
|
||||
"aiEgressRefusal",
|
||||
] as const;
|
||||
|
||||
function importsAConsentHelper(source: string): boolean {
|
||||
const imported = importedNamesFrom(source, "@/lib/ai/consent-guard");
|
||||
return CONSENT_HELPERS.some((h) => imported.includes(h));
|
||||
const egress = importedNamesFrom(source, "@/lib/ai/capabilities/egress");
|
||||
return (
|
||||
CONSENT_HELPERS.some((h) => imported.includes(h)) ||
|
||||
CAPABILITY_EGRESS_HELPERS.some((h) => egress.includes(h))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,8 +139,6 @@ function importsAConsentHelper(source: string): boolean {
|
||||
*/
|
||||
const ALLOWLIST: Record<string, string> = {
|
||||
// --- Capability / config probes: resolve a chain, never call it. ---
|
||||
"lib/labs/ocr-capability.ts":
|
||||
"Resolves the chain to REPORT which entry could do vision/text OCR (mode, reason, pdfSupported). Makes no completion call. Every consumer that then egresses a document is gated at its own call site with assertDocumentEgressConsent.",
|
||||
"app/api/insights/provider-chain/route.ts":
|
||||
"Lists the user's configured chain and which entry is active, for the settings UI. Constructs clients but never calls generateCompletion. No health data in the request or the response.",
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Structural guard: background and insight readers must not materialise a
|
||||
* dense measurement stream.
|
||||
*
|
||||
* #1023: a single account syncing heart rate from a watch took the worker
|
||||
* down within half an hour. The status-card read was bounded in time
|
||||
* (`measuredAt >= now − 91 days`) but not in rows, and a stream sampled every
|
||||
* minute or faster turns 91 days into six or seven figures of objects. The
|
||||
* read also ran into the statement timeout. The cure was to fold per day in
|
||||
* SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks
|
||||
* the number of days, not the sampling rate.
|
||||
*
|
||||
* What this guard pins, in the directories that run on the worker or feed an
|
||||
* insight: every `measurement.findMany` without a `take` reads a type that is
|
||||
* named as a literal and is not one a device streams. A read whose type is a
|
||||
* variable could be handed a dense type by the next caller, so it must be
|
||||
* listed below with the reason it is safe. The list is frozen both ways: a new
|
||||
* unlisted read fails, and an entry whose read is gone fails too.
|
||||
*
|
||||
* It is a tripwire, not a proof: the matcher reads source text, and a read
|
||||
* written some other way (a raw SELECT of rows, a helper in another
|
||||
* directory) passes it. The floor below fails the guard if the matcher stops
|
||||
* finding reads at all.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
import {
|
||||
CUMULATIVE_HK_TYPES,
|
||||
HIGH_FREQUENCY_MEAN_TYPES,
|
||||
} from "@/lib/measurements/apple-health-mapping";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const SCOPED_DIRS = [
|
||||
"lib/insights/",
|
||||
"lib/jobs/",
|
||||
"lib/rollups/",
|
||||
"lib/ai/",
|
||||
"lib/analytics/",
|
||||
"lib/dashboard/",
|
||||
"lib/daily/",
|
||||
];
|
||||
|
||||
/** Types a device writes at sampling rate rather than a few times a day. */
|
||||
const DENSE_TYPES: ReadonlySet<string> = new Set([
|
||||
"PULSE",
|
||||
"BLOOD_GLUCOSE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"OXYGEN_SATURATION",
|
||||
...CUMULATIVE_HK_TYPES,
|
||||
...HIGH_FREQUENCY_MEAN_TYPES,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Untaken reads whose type is not a sparse literal, keyed
|
||||
* `<file>::<type expression>`, with why each stays bounded.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"lib/insights/comprehensive-generate.ts::(shorthand)":
|
||||
'Only reached for SLEEP_DURATION (guarded by `type === "SLEEP_DURATION"`); a few stage rows per night.',
|
||||
"lib/insights/derived/vascular-age.ts::VASCULAR_AGE_TYPE":
|
||||
"A constant for a type written at most a few times a day.",
|
||||
"lib/insights/derived/fitness-age.ts::VO2_MAX_TYPE":
|
||||
"A constant for VO2 max, written at most daily.",
|
||||
"lib/insights/derived/six-minute-walk.ts::SIX_MINUTE_WALK_TYPE":
|
||||
"A constant for six-minute-walk distance, written at most daily.",
|
||||
"lib/insights/derived/wellness-scores.ts::measurementType":
|
||||
"Daily computed / provider scores (recovery, strain, readiness), one row per day.",
|
||||
"lib/jobs/reaction-line.ts::{ in: [...types]":
|
||||
"Exact-instant match (`measuredAt: row.occurredAt`), not a window.",
|
||||
"lib/jobs/step-consolidation-repair.ts::STEP_TYPE":
|
||||
"Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.",
|
||||
'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never':
|
||||
"Clinical CGM metrics (time in range, SD / CV, J-index, LBGI / HBGI, reading span) are functions of the individual readings, which a per-day aggregate cannot reproduce. 30 days at a sensor's fixed rate (at most one a minute) stays below 45 000 rows.",
|
||||
'lib/dashboard/snapshot.ts::"BLOOD_GLUCOSE"':
|
||||
"Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.",
|
||||
"lib/analytics/score/reader.ts::{ in: types":
|
||||
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
|
||||
};
|
||||
|
||||
interface UntakenRead {
|
||||
file: string;
|
||||
line: number;
|
||||
typeExpr: string;
|
||||
}
|
||||
|
||||
function scopedFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d)))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function untakenReads(): { all: number; untaken: UntakenRead[] } {
|
||||
let all = 0;
|
||||
const untaken: UntakenRead[] = [];
|
||||
for (const file of scopedFiles()) {
|
||||
const src = readFileSync(join(SRC, file), "utf8");
|
||||
// Whitespace-tolerant: a call split across lines still matches.
|
||||
const re = /\.measurement\s*\.\s*findMany\s*\(/g;
|
||||
for (let m = re.exec(src); m; m = re.exec(src)) {
|
||||
all += 1;
|
||||
let i = m.index + m[0].length;
|
||||
let depth = 1;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === "(") depth += 1;
|
||||
else if (src[i] === ")") depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
const call = src.slice(m.index, i);
|
||||
if (/\btake\b/.test(call)) continue;
|
||||
const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call);
|
||||
const typeExpr = typed
|
||||
? typed[1].trim()
|
||||
: /\btype\s*,/.test(call)
|
||||
? "(shorthand)"
|
||||
: "(none)";
|
||||
untaken.push({
|
||||
file,
|
||||
line: src.slice(0, m.index).split("\n").length,
|
||||
typeExpr,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { all, untaken };
|
||||
}
|
||||
|
||||
function sparseLiteral(typeExpr: string): boolean {
|
||||
const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr);
|
||||
return lit !== null && !DENSE_TYPES.has(lit[1]);
|
||||
}
|
||||
|
||||
describe("dense measurement reads stay bounded (#1023)", () => {
|
||||
const { all, untaken } = untakenReads();
|
||||
|
||||
it("finds the reads it is meant to police", () => {
|
||||
// Guard against a matcher that silently matches nothing.
|
||||
expect(all).toBeGreaterThanOrEqual(40);
|
||||
expect(untaken.length).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
|
||||
it("every untaken read names a sparse literal type or is listed with its reason", () => {
|
||||
const offenders = untaken
|
||||
.filter((r) => !sparseLiteral(r.typeExpr))
|
||||
.filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED))
|
||||
.map((r) => `${r.file}:${r.line} type ${r.typeExpr}`);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("every listed exception still exists", () => {
|
||||
const present = new Set(untaken.map((r) => `${r.file}::${r.typeExpr}`));
|
||||
expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]);
|
||||
});
|
||||
|
||||
it("the readers fixed for #1023 fold in SQL rather than reading rows", () => {
|
||||
for (const file of [
|
||||
"lib/insights/graded-series.ts",
|
||||
"lib/rollups/tiered-context.ts",
|
||||
"lib/jobs/coach-plan-review.ts",
|
||||
"lib/measurements/daily-series-read.ts",
|
||||
]) {
|
||||
const src = readFileSync(join(SRC, file), "utf8");
|
||||
expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
|
||||
}
|
||||
const baseline = readFileSync(
|
||||
join(SRC, "lib/insights/derived/baseline.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* A `@/lib/documents/provider-order` mock for the vault route suites.
|
||||
*
|
||||
* The suites steer the document pick through `resolveDocumentVisionProvider` /
|
||||
* `resolveDocumentTextProvider` (a `{ chain, pick }` per case). The routes call
|
||||
* the `require*` forms, which throw instead of returning an empty pick; these
|
||||
* are built on the two mocked resolvers so a case that returns no pick sees
|
||||
* the route answer the vault's own `documents.inbound.providerUnsupported`
|
||||
* 422, and a case that returns a `withheld` refusal sees it thrown.
|
||||
*
|
||||
* Use inside a factory, where only a dynamic import is allowed:
|
||||
*
|
||||
* vi.mock("@/lib/documents/provider-order", async () =>
|
||||
* (await import("@/__tests__/helpers/provider-order-mock")).providerOrderMock(),
|
||||
* );
|
||||
*/
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
|
||||
interface MockPick {
|
||||
pick: unknown;
|
||||
withheld?: AiUnavailableError | null;
|
||||
}
|
||||
|
||||
async function required(resolved: Promise<MockPick> | MockPick) {
|
||||
const result = await resolved;
|
||||
if (result?.withheld) throw result.withheld;
|
||||
if (!result?.pick) {
|
||||
throw new AiUnavailableError("documentAi", "no_provider", null, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
status: 422,
|
||||
});
|
||||
}
|
||||
return result.pick;
|
||||
}
|
||||
|
||||
export function providerOrderMock() {
|
||||
const resolveDocumentVisionProvider = vi.fn();
|
||||
const resolveDocumentTextProvider = vi.fn();
|
||||
return {
|
||||
resolveDocumentVisionProvider,
|
||||
resolveDocumentTextProvider,
|
||||
requireDocumentVisionProvider: vi.fn((userId: string) =>
|
||||
required(resolveDocumentVisionProvider(userId)),
|
||||
),
|
||||
requireDocumentTextProvider: vi.fn((userId: string) =>
|
||||
required(resolveDocumentTextProvider(userId)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The capability gate, open. Pair with `vi.mock("@/lib/ai/capabilities/gate", …)`. */
|
||||
export function openCapabilityGateMock() {
|
||||
return {
|
||||
requireAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
getAiCapability: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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}":
|
||||
|
||||
Reference in new issue
Block a user