mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(coach): let the capability table decide the about-me question consent
The aboutMeQuestions capability admits an ai_insights_only receipt, but the question generator ran its own gate that demanded a Coach receipt, so a person whose capability read available still got the fallback questions on an operator-held chain. The generator now runs the shared wire re-check (aiEgressRefusal) for the aboutMeQuestions key, so the receipt kinds and the rule for when one is needed come from the capability table alone.
This commit is contained in:
1 parent
091e4b84cb
commit
f700a7c0f1
2 files changed
+77
-31
No files matched your search
@@ -14,16 +14,33 @@
|
||||
* - the budget runs through the ATOMIC reserve/reconcile pair, not a
|
||||
* read-then-write check.
|
||||
*
|
||||
* `@/lib/consent/receipts` is mocked rather than the consent guard itself, so
|
||||
* the real `chainRequiresServerManagedConsent` + `hasActiveConsentForSurface`
|
||||
* logic is what these tests exercise.
|
||||
* Only the receipt read is mocked, so the real wire re-check
|
||||
* (`aiEgressRefusal`) and the capability table's `aboutMeQuestions` row are
|
||||
* what these tests exercise: which receipts cover the prompt is the table's
|
||||
* answer, not this module's.
|
||||
*/
|
||||
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 }));
|
||||
const activeKinds = vi.hoisted(() => ({ kinds: new Set<string>() }));
|
||||
const consentFindFirst = vi.hoisted(() =>
|
||||
vi.fn(async (args: { where: { kind: { in: string[] } } }) =>
|
||||
args.where.kind.in.some((kind) => activeKinds.kinds.has(kind))
|
||||
? { id: "receipt" }
|
||||
: null,
|
||||
),
|
||||
);
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { consentReceipt: { findFirst: consentFindFirst } },
|
||||
}));
|
||||
// The capability itself is open; the route resolved it before calling in.
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
aiCapabilityForJob: vi.fn(async () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
})),
|
||||
requireAiCapability: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/documents/document-settings", () => ({
|
||||
documentAutoReadEnabled: vi.fn(async () => false),
|
||||
}));
|
||||
@@ -54,9 +71,13 @@ vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
|
||||
const buildCoachSnapshot = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot }));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => null,
|
||||
}));
|
||||
|
||||
import { deriveClarifyingQuestions } from "../self-context-questions";
|
||||
import { AI_CAPABILITIES } from "@/lib/ai/capabilities/types";
|
||||
import type { SelfContext } from "../about-me";
|
||||
import { DEFAULT_HEALTH_PROFILE_AI_SECTIONS } from "@/lib/validations/health-profile-facts";
|
||||
|
||||
@@ -85,7 +106,7 @@ let provider: ReturnType<typeof makeProvider>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provider = makeProvider();
|
||||
latestActiveReceipt.mockResolvedValue(null);
|
||||
activeKinds.kinds = new Set();
|
||||
providerMocks.hasAnyConfiguredProvider.mockResolvedValue(true);
|
||||
providerMocks.resolveProvider.mockResolvedValue({ type: "none" });
|
||||
budgetMocks.reserveBudget.mockResolvedValue({
|
||||
@@ -215,9 +236,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
|
||||
kind === "ai_coach" ? { id: "receipt-1" } : null,
|
||||
);
|
||||
activeKinds.kinds = new Set(["ai_coach"]);
|
||||
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
@@ -236,9 +255,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
latestActiveReceipt.mockImplementation(async (_userId, kind) =>
|
||||
kind === "ai_full" ? { id: "receipt-2" } : null,
|
||||
);
|
||||
activeKinds.kinds = new Set(["ai_full"]);
|
||||
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
@@ -252,6 +269,32 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts the analysis-only grant the capability table lists", async () => {
|
||||
// The capability the route resolved admits `ai_insights_only`; the
|
||||
// question path used to demand a Coach receipt on top and fell back.
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: provider },
|
||||
]);
|
||||
activeKinds.kinds = new Set(["ai_insights_only"]);
|
||||
|
||||
const out = await deriveClarifyingQuestions(
|
||||
"user-1",
|
||||
ctx,
|
||||
"en",
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
{ aiAvailable: true },
|
||||
);
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(consentFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
kind: { in: [...AI_CAPABILITIES.aboutMeQuestions.consent.kinds] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a BYOK chain ungated — the user's own egress needs no receipt", async () => {
|
||||
providerMocks.resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "openai", instance: provider },
|
||||
@@ -267,7 +310,7 @@ describe("deriveClarifyingQuestions — server-managed consent gate", () => {
|
||||
|
||||
expect(out.source).toBe("ai");
|
||||
expect(provider.generateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(latestActiveReceipt).not.toHaveBeenCalled();
|
||||
expect(consentFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a local chain ungated", async () => {
|
||||
|
||||
@@ -36,10 +36,7 @@ import {
|
||||
resolveProviderChain,
|
||||
} from "@/lib/ai/provider";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import {
|
||||
chainRequiresServerManagedConsent,
|
||||
hasActiveConsentForSurface,
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import {
|
||||
buildDateKey,
|
||||
@@ -213,18 +210,24 @@ export async function deriveClarifyingQuestions(
|
||||
const chain = await resolveQuestionsChain(userId);
|
||||
if (chain === null) return fallback();
|
||||
|
||||
// Consent gate — BEFORE the snapshot is built, let alone sent. This
|
||||
// prompt ships the complete Coach snapshot, so a chain that could egress
|
||||
// via the operator's server-managed credential needs an active
|
||||
// `ai_coach` / `ai_full` receipt. BYOK / local / ChatGPT-OAuth chains are
|
||||
// the user's own egress and stay ungated, matching every other surface.
|
||||
if (
|
||||
chainRequiresServerManagedConsent(chain) &&
|
||||
!(await hasActiveConsentForSurface(userId, "coach"))
|
||||
) {
|
||||
// The wire re-check — BEFORE the snapshot is built, let alone sent. The
|
||||
// capability table's `aboutMeQuestions` row decides which receipts cover
|
||||
// this prompt and when one is needed (a chain that could reach the
|
||||
// operator's key or the shared central Codex; a person's own key, their
|
||||
// ChatGPT account or a local model need none), so this path and the
|
||||
// capability the route resolved cannot disagree.
|
||||
const refusal = await aiEgressRefusal(
|
||||
"aboutMeQuestions",
|
||||
userId,
|
||||
chain.map((entry) => entry.providerType),
|
||||
);
|
||||
if (refusal) {
|
||||
annotate({
|
||||
action: { name: "coach.self_context.consent_required" },
|
||||
meta: { self_context_questions_source: "fallback" },
|
||||
action: { name: "coach.self_context.egress_refused" },
|
||||
meta: {
|
||||
self_context_questions_source: "fallback",
|
||||
reason: refusal.reason,
|
||||
},
|
||||
});
|
||||
return fallback();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user