mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(ai): resolve AI capabilities from their layers
A pure resolver turns operator switches, module access, provider-work authority, provider presence and consent receipts into one state per capability, reporting the outermost reason. The document provider rank moves to its own module so the resolver and the document routes read the same order.
This commit is contained in:
1 parent
a5fdf675e1
commit
c8a01ac3ed
4 files changed
+811
-32
No files matched your search
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* The capability resolver, exhaustively.
|
||||
*
|
||||
* Three tables. Each reason is produced on its own for every capability it can
|
||||
* apply to. Every ordered pair of reasons applied together reports the outer
|
||||
* one, which is the precedence contract clients are promised. And the cases
|
||||
* where the layers interact (provider order, document picks, consent kinds,
|
||||
* the "either module" capability) are pinned one by one.
|
||||
*
|
||||
* Mutation check: swapping two entries of `AI_UNAVAILABLE_REASONS` turns the
|
||||
* pair table red, naming the pair; dropping a layer from `resolveAiCapability`
|
||||
* turns its single-reason row red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveAiBlock,
|
||||
resolveAiCapabilities,
|
||||
resolveAiCapability,
|
||||
resolveAiProviderState,
|
||||
type AiCapabilityInputs,
|
||||
type ProviderEntryPresence,
|
||||
} from "../resolve";
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
AI_CAPABILITY_KEYS,
|
||||
AI_OPT_OUT_MODULES,
|
||||
AI_OPERATOR_SWITCHES,
|
||||
AI_UNAVAILABLE_REASONS,
|
||||
type AiCapabilityKey,
|
||||
type AiUnavailableReason,
|
||||
} from "../types";
|
||||
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
|
||||
function allEnabled(): Record<ModuleKey, ModuleAccessState> {
|
||||
const out = {} as Record<ModuleKey, ModuleAccessState>;
|
||||
for (const key of MODULE_KEYS) out[key] = "enabled";
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Everything on, a local vision model, no receipts: every capability is available. */
|
||||
function baseline(): AiCapabilityInputs {
|
||||
return {
|
||||
switches: {
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
},
|
||||
moduleAccess: allEnabled(),
|
||||
providerWorkAdmitted: true,
|
||||
provider: {
|
||||
entries: [{ providerType: "local", vision: true }],
|
||||
localOcrEnabled: false,
|
||||
managedBy: "local",
|
||||
},
|
||||
activeConsentKinds: new Set(),
|
||||
recordKind: "self",
|
||||
};
|
||||
}
|
||||
|
||||
type Perturb = (inputs: AiCapabilityInputs, key: AiCapabilityKey) => boolean;
|
||||
|
||||
/**
|
||||
* One way to produce each reason (bar `check_failed`, which is null inputs).
|
||||
* Returns false when the reason cannot apply to the capability, for example a
|
||||
* module reason on a capability no module owns.
|
||||
*/
|
||||
const PERTURB: Record<Exclude<AiUnavailableReason, "check_failed">, Perturb> = {
|
||||
operator_disabled: (inputs, key) => {
|
||||
inputs.switches = {
|
||||
...inputs.switches,
|
||||
[AI_CAPABILITIES[key].operatorSwitch]: false,
|
||||
};
|
||||
return true;
|
||||
},
|
||||
not_permitted_for_record: (inputs) => {
|
||||
inputs.providerWorkAdmitted = false;
|
||||
return true;
|
||||
},
|
||||
module_disabled: (inputs, key) => {
|
||||
const { keys } = AI_CAPABILITIES[key].modules;
|
||||
if (keys.length === 0) return false;
|
||||
const access = { ...inputs.moduleAccess };
|
||||
for (const moduleKey of keys) access[moduleKey] = "not_granted";
|
||||
inputs.moduleAccess = access;
|
||||
return true;
|
||||
},
|
||||
user_disabled: (inputs, key) => {
|
||||
const { mode, keys } = AI_CAPABILITIES[key].modules;
|
||||
const optOuts = keys.filter((moduleKey) =>
|
||||
AI_OPT_OUT_MODULES.has(moduleKey),
|
||||
);
|
||||
if (optOuts.length === 0) return false;
|
||||
// An "any" capability needs every path closed.
|
||||
if (mode === "any" && optOuts.length !== keys.length) return false;
|
||||
const access = { ...inputs.moduleAccess };
|
||||
for (const moduleKey of optOuts) {
|
||||
if (access[moduleKey] === "enabled") access[moduleKey] = "disabled";
|
||||
}
|
||||
inputs.moduleAccess = access;
|
||||
return true;
|
||||
},
|
||||
no_provider: (inputs) => {
|
||||
inputs.provider = { ...inputs.provider, entries: [], managedBy: null };
|
||||
return true;
|
||||
},
|
||||
consent_required: (inputs) => {
|
||||
inputs.provider = {
|
||||
...inputs.provider,
|
||||
entries: [{ providerType: "admin-openai", vision: true }],
|
||||
managedBy: "server",
|
||||
};
|
||||
inputs.activeConsentKinds = new Set();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const PERTURBABLE = AI_UNAVAILABLE_REASONS.filter(
|
||||
(reason): reason is Exclude<AiUnavailableReason, "check_failed"> =>
|
||||
reason !== "check_failed",
|
||||
);
|
||||
|
||||
describe("baseline", () => {
|
||||
it("makes every capability available", () => {
|
||||
const resolved = resolveAiCapabilities(baseline());
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
expect(resolved[key], key).toEqual({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes every capability key and nothing else", () => {
|
||||
expect(Object.keys(resolveAiCapabilities(baseline())).sort()).toEqual(
|
||||
[...AI_CAPABILITY_KEYS].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("each reason on its own", () => {
|
||||
it("fails closed to check_failed when the inputs could not be loaded", () => {
|
||||
const resolved = resolveAiCapabilities(null);
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
expect(resolved[key]).toEqual({
|
||||
available: false,
|
||||
reason: "check_failed",
|
||||
onDeviceAllowed: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const reason of PERTURBABLE) {
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
const inputs = baseline();
|
||||
if (!PERTURB[reason](inputs, key)) continue;
|
||||
it(`${key}: ${reason}`, () => {
|
||||
expect(resolveAiCapability(key, inputs).reason).toBe(reason);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("covers every reason for at least one capability", () => {
|
||||
for (const reason of PERTURBABLE) {
|
||||
const applies = AI_CAPABILITY_KEYS.some((key) =>
|
||||
PERTURB[reason](baseline(), key),
|
||||
);
|
||||
expect(applies, reason).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("every pair of reasons reports the outer one", () => {
|
||||
let checked = 0;
|
||||
for (let i = 0; i < PERTURBABLE.length; i++) {
|
||||
for (let j = i + 1; j < PERTURBABLE.length; j++) {
|
||||
const outer = PERTURBABLE[i]!;
|
||||
const inner = PERTURBABLE[j]!;
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
const inputs = baseline();
|
||||
// Inner first: the provider perturbations overwrite the same field,
|
||||
// and the outer one has to be the one that sticks.
|
||||
if (!PERTURB[inner](inputs, key)) continue;
|
||||
if (!PERTURB[outer](inputs, key)) continue;
|
||||
checked++;
|
||||
it(`${key}: ${outer} over ${inner}`, () => {
|
||||
expect(resolveAiCapability(key, inputs).reason).toBe(outer);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("ran a non-trivial pair table", () => {
|
||||
expect(checked).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the operator layer", () => {
|
||||
it("master off forces every capability off, whatever else is wrong", () => {
|
||||
const inputs = baseline();
|
||||
inputs.switches = { ...inputs.switches, enabled: false };
|
||||
inputs.providerWorkAdmitted = false;
|
||||
inputs.provider = { ...inputs.provider, entries: [] };
|
||||
const resolved = resolveAiCapabilities(inputs);
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
expect(resolved[key].reason, key).toBe("operator_disabled");
|
||||
}
|
||||
});
|
||||
|
||||
it("a sub-switch closes exactly the capabilities it covers", () => {
|
||||
for (const toggle of AI_OPERATOR_SWITCHES) {
|
||||
const inputs = baseline();
|
||||
inputs.switches = { ...inputs.switches, [toggle]: false };
|
||||
const resolved = resolveAiCapabilities(inputs);
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
const covered = AI_CAPABILITIES[key].operatorSwitch === toggle;
|
||||
expect(resolved[key].available, `${toggle} → ${key}`).toBe(!covered);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reads operator module availability as an operator decision", () => {
|
||||
const inputs = baseline();
|
||||
inputs.moduleAccess = { ...inputs.moduleAccess, insights: "unavailable" };
|
||||
expect(resolveAiCapability("briefing", inputs).reason).toBe(
|
||||
"operator_disabled",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the module layer", () => {
|
||||
it("reads the record's own switch on an opt-out module as the person's choice", () => {
|
||||
const inputs = baseline();
|
||||
inputs.moduleAccess = { ...inputs.moduleAccess, coach: "disabled" };
|
||||
expect(resolveAiCapability("coach", inputs).reason).toBe("user_disabled");
|
||||
});
|
||||
|
||||
it("reads the record's own switch on any other module as module_disabled", () => {
|
||||
const inputs = baseline();
|
||||
inputs.moduleAccess = { ...inputs.moduleAccess, workouts: "disabled" };
|
||||
expect(resolveAiCapability("workoutInsights", inputs).reason).toBe(
|
||||
"module_disabled",
|
||||
);
|
||||
expect(resolveAiCapability("statusText", inputs).available).toBe(true);
|
||||
});
|
||||
|
||||
it("closes workout notes when either of its two modules is off", () => {
|
||||
const inputs = baseline();
|
||||
inputs.moduleAccess = { ...inputs.moduleAccess, insights: "disabled" };
|
||||
expect(resolveAiCapability("workoutInsights", inputs).reason).toBe(
|
||||
"user_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the about-me questions while either consumer is on", () => {
|
||||
const inputs = baseline();
|
||||
inputs.moduleAccess = { ...inputs.moduleAccess, coach: "disabled" };
|
||||
expect(resolveAiCapability("aboutMeQuestions", inputs).available).toBe(
|
||||
true,
|
||||
);
|
||||
inputs.moduleAccess = {
|
||||
...inputs.moduleAccess,
|
||||
coach: "enabled",
|
||||
insights: "disabled",
|
||||
};
|
||||
expect(resolveAiCapability("aboutMeQuestions", inputs).available).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the reason nearest to available when both consumers are off", () => {
|
||||
const inputs = baseline();
|
||||
inputs.moduleAccess = {
|
||||
...inputs.moduleAccess,
|
||||
coach: "not_granted",
|
||||
insights: "disabled",
|
||||
};
|
||||
expect(resolveAiCapability("aboutMeQuestions", inputs).reason).toBe(
|
||||
"user_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives medication extraction no owning module", () => {
|
||||
const inputs = baseline();
|
||||
const access = { ...inputs.moduleAccess };
|
||||
for (const key of MODULE_KEYS) access[key] = "disabled";
|
||||
inputs.moduleAccess = access;
|
||||
expect(resolveAiCapability("medicationExtract", inputs).available).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider presence per modality", () => {
|
||||
const openaiText: ProviderEntryPresence = {
|
||||
providerType: "openai",
|
||||
vision: false,
|
||||
};
|
||||
|
||||
it("serves text from any entry and a document only from a vision entry", () => {
|
||||
const inputs = baseline();
|
||||
inputs.provider = { ...inputs.provider, entries: [openaiText] };
|
||||
inputs.activeConsentKinds = new Set(["ai_full"]);
|
||||
const resolved = resolveAiCapabilities(inputs);
|
||||
expect(resolved.statusText.available).toBe(true);
|
||||
expect(resolved.medicationExtract.available).toBe(true);
|
||||
expect(resolved.documentAi.reason).toBe("no_provider");
|
||||
expect(resolved.labsOcr.reason).toBe("no_provider");
|
||||
});
|
||||
|
||||
it("lets a text provider read a document once in-browser OCR is on", () => {
|
||||
const inputs = baseline();
|
||||
inputs.provider = {
|
||||
...inputs.provider,
|
||||
entries: [openaiText],
|
||||
localOcrEnabled: true,
|
||||
};
|
||||
inputs.activeConsentKinds = new Set(["ai_extraction"]);
|
||||
expect(resolveAiCapability("documentAi", inputs).available).toBe(true);
|
||||
expect(resolveAiCapability("labsOcr", inputs).available).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the consent layer", () => {
|
||||
it("needs no receipt for a person's own key on a self-snapshot capability", () => {
|
||||
const inputs = baseline();
|
||||
inputs.provider = {
|
||||
...inputs.provider,
|
||||
entries: [{ providerType: "anthropic", vision: true }],
|
||||
};
|
||||
expect(resolveAiCapability("coach", inputs).available).toBe(true);
|
||||
expect(resolveAiCapability("briefing", inputs).available).toBe(true);
|
||||
});
|
||||
|
||||
it("needs a receipt once the operator's key is anywhere in the chain", () => {
|
||||
const inputs = baseline();
|
||||
inputs.provider = {
|
||||
...inputs.provider,
|
||||
entries: [
|
||||
{ providerType: "anthropic", vision: true },
|
||||
{ providerType: "admin-codex", vision: true },
|
||||
],
|
||||
};
|
||||
expect(resolveAiCapability("coach", inputs).reason).toBe(
|
||||
"consent_required",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches kinds to groups, with ai_full covering all of them", () => {
|
||||
const inputs = baseline();
|
||||
inputs.provider = {
|
||||
...inputs.provider,
|
||||
entries: [{ providerType: "admin-openai", vision: true }],
|
||||
};
|
||||
const cases: [string, AiCapabilityKey[], AiCapabilityKey[]][] = [
|
||||
[
|
||||
"ai_coach",
|
||||
["coach", "aboutMeQuestions"],
|
||||
["briefing", "statusText", "documentAi", "labsOcr"],
|
||||
],
|
||||
[
|
||||
"ai_insights_only",
|
||||
["briefing", "periodNarrative", "statusText", "aboutMeQuestions"],
|
||||
["coach", "documentAi", "medicationExtract"],
|
||||
],
|
||||
[
|
||||
"ai_extraction",
|
||||
["documentAi", "labsOcr", "medicationExtract"],
|
||||
["coach", "briefing", "statusText", "aboutMeQuestions"],
|
||||
],
|
||||
["ai_full", [...AI_CAPABILITY_KEYS], []],
|
||||
];
|
||||
for (const [kind, open, closed] of cases) {
|
||||
inputs.activeConsentKinds = new Set([kind]);
|
||||
const resolved = resolveAiCapabilities(inputs);
|
||||
for (const key of open) {
|
||||
expect(resolved[key].available, `${kind} opens ${key}`).toBe(true);
|
||||
}
|
||||
for (const key of closed) {
|
||||
expect(resolved[key].reason, `${kind} leaves ${key}`).toBe(
|
||||
"consent_required",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("needs a receipt for any external document read, the person's own key included", () => {
|
||||
const inputs = baseline();
|
||||
inputs.provider = {
|
||||
...inputs.provider,
|
||||
entries: [{ providerType: "anthropic", vision: true }],
|
||||
};
|
||||
expect(resolveAiCapability("documentAi", inputs).reason).toBe(
|
||||
"consent_required",
|
||||
);
|
||||
expect(resolveAiCapability("medicationExtract", inputs).reason).toBe(
|
||||
"consent_required",
|
||||
);
|
||||
});
|
||||
|
||||
it("picks a document provider in document order and labs in chain order", () => {
|
||||
const inputs = baseline();
|
||||
// The ChatGPT-subscription path first in the chain, a local model behind it.
|
||||
inputs.provider = {
|
||||
...inputs.provider,
|
||||
entries: [
|
||||
{ providerType: "codex", vision: true },
|
||||
{ providerType: "local", vision: true },
|
||||
],
|
||||
};
|
||||
// Document order puts local first: nothing leaves the machine.
|
||||
expect(resolveAiCapability("documentAi", inputs).available).toBe(true);
|
||||
// The labs scan keeps the chain's own order and picks the external entry.
|
||||
expect(resolveAiCapability("labsOcr", inputs).reason).toBe(
|
||||
"consent_required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-device permission", () => {
|
||||
it("follows the operator and the person, not the server provider or server consent", () => {
|
||||
const expected: Record<AiUnavailableReason, boolean> = {
|
||||
check_failed: false,
|
||||
operator_disabled: false,
|
||||
not_permitted_for_record: false,
|
||||
module_disabled: false,
|
||||
user_disabled: false,
|
||||
no_provider: true,
|
||||
consent_required: true,
|
||||
};
|
||||
for (const reason of PERTURBABLE) {
|
||||
const inputs = baseline();
|
||||
PERTURB[reason](inputs, "briefing");
|
||||
expect(
|
||||
resolveAiCapability("briefing", inputs).onDeviceAllowed,
|
||||
reason,
|
||||
).toBe(expected[reason]);
|
||||
}
|
||||
expect(resolveAiCapability("briefing", null).onDeviceAllowed).toBe(
|
||||
expected.check_failed,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the provider block", () => {
|
||||
it("reports presence and origin", () => {
|
||||
expect(resolveAiProviderState(baseline())).toEqual({
|
||||
configured: true,
|
||||
managedBy: "local",
|
||||
canConfigure: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers setup only on one's own record, and only with the master on", () => {
|
||||
for (const recordKind of ["shared", "managed"] as const) {
|
||||
const inputs = baseline();
|
||||
inputs.recordKind = recordKind;
|
||||
expect(resolveAiProviderState(inputs).canConfigure, recordKind).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
const inputs = baseline();
|
||||
inputs.switches = { ...inputs.switches, enabled: false };
|
||||
expect(resolveAiProviderState(inputs).canConfigure).toBe(false);
|
||||
});
|
||||
|
||||
it("reports nothing configured when the inputs failed to load", () => {
|
||||
expect(resolveAiBlock(null).provider).toEqual({
|
||||
configured: false,
|
||||
managedBy: null,
|
||||
canConfigure: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* The AI capability resolver: pure, total, no I/O.
|
||||
*
|
||||
* Every layer that can say no to AI work is an input here, and each can only
|
||||
* subtract. The answer per capability is one of `available` or the OUTERMOST
|
||||
* reason that applies (precedence in {@link AI_UNAVAILABLE_REASONS}), because
|
||||
* that is the layer the reader would have to change first. Nothing in this
|
||||
* file reads a database, a clock, a request or an environment variable; the
|
||||
* loader (`./load.ts`) gathers the inputs and this decides.
|
||||
*
|
||||
* Deliberately NOT an input: budgets and rate limits. They move minute to
|
||||
* minute, and a cached payload that published them would be wrong by the time
|
||||
* a client acted on it. They stay a per-call refusal with their own codes.
|
||||
*/
|
||||
import { documentProviderRank } from "@/lib/documents/provider-rank";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
|
||||
|
||||
import {
|
||||
AI_CAPABILITIES,
|
||||
AI_CAPABILITY_KEYS,
|
||||
AI_OPT_OUT_MODULES,
|
||||
AI_UNAVAILABLE_REASONS,
|
||||
ON_DEVICE_ALLOWED_REASONS,
|
||||
type AiCapabilities,
|
||||
type AiCapabilityDefinition,
|
||||
type AiCapabilityKey,
|
||||
type AiCapabilityState,
|
||||
type AiOperatorSwitchSet,
|
||||
type AiProviderManagedBy,
|
||||
type AiProviderState,
|
||||
type AiUnavailableReason,
|
||||
} from "./types";
|
||||
|
||||
/** One configured provider in the chain, presence only. */
|
||||
export interface ProviderEntryPresence {
|
||||
/** The chain tag: `openai`, `anthropic`, `local`, `admin-openai`, … */
|
||||
providerType: string;
|
||||
/** Whether the entry's model can read an image. */
|
||||
vision: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the provider chain for a record looks like, without building a client,
|
||||
* refreshing a token or touching the network. Already narrowed by the
|
||||
* provider-work authority: a delegate's chain is empty, a guardian's is the
|
||||
* operator's key alone.
|
||||
*/
|
||||
export interface ProviderPresence {
|
||||
/** Usable entries in chain order, the central Codex and legacy fallback included. */
|
||||
entries: readonly ProviderEntryPresence[];
|
||||
/** The person's in-browser OCR opt-in, which lets a text-only provider read a document. */
|
||||
localOcrEnabled: boolean;
|
||||
managedBy: AiProviderManagedBy | null;
|
||||
}
|
||||
|
||||
/** Whose record this is, as far as configuring a provider for it goes. */
|
||||
export type AiRecordKind = "self" | "shared" | "managed";
|
||||
|
||||
export interface AiCapabilityInputs {
|
||||
/** Operator switches, master already applied. */
|
||||
switches: AiOperatorSwitchSet;
|
||||
/** Per-module access for the record, as `moduleAccess` publishes it. */
|
||||
moduleAccess: Readonly<Record<ModuleKey, ModuleAccessState>>;
|
||||
/** Whether provider work is admitted for this record at all. */
|
||||
providerWorkAdmitted: boolean;
|
||||
provider: ProviderPresence;
|
||||
/** Kinds of the record's active (non-revoked) consent receipts. */
|
||||
activeConsentKinds: ReadonlySet<string>;
|
||||
recordKind: AiRecordKind;
|
||||
}
|
||||
|
||||
/** Provider tags that egress through a credential the operator holds. */
|
||||
const OPERATOR_HELD_PROVIDER_TYPES: ReadonlySet<string> = new Set([
|
||||
"admin-openai",
|
||||
"admin-codex",
|
||||
]);
|
||||
|
||||
/** The one provider tag that keeps its input on the machine. */
|
||||
const LOCAL_PROVIDER_TYPE = "local";
|
||||
|
||||
const REASON_RANK: ReadonlyMap<AiUnavailableReason, number> = new Map(
|
||||
AI_UNAVAILABLE_REASONS.map((reason, index) => [reason, index]),
|
||||
);
|
||||
|
||||
function outermost(
|
||||
reasons: readonly (AiUnavailableReason | null)[],
|
||||
): AiUnavailableReason | null {
|
||||
let best: AiUnavailableReason | null = null;
|
||||
for (const reason of reasons) {
|
||||
if (reason === null) continue;
|
||||
if (best === null || REASON_RANK.get(reason)! < REASON_RANK.get(best)!) {
|
||||
best = reason;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function innermost(
|
||||
reasons: readonly AiUnavailableReason[],
|
||||
): AiUnavailableReason | null {
|
||||
let best: AiUnavailableReason | null = null;
|
||||
for (const reason of reasons) {
|
||||
if (best === null || REASON_RANK.get(reason)! > REASON_RANK.get(best)!) {
|
||||
best = reason;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function moduleReason(
|
||||
key: ModuleKey,
|
||||
access: ModuleAccessState,
|
||||
): AiUnavailableReason | null {
|
||||
switch (access) {
|
||||
case "enabled":
|
||||
return null;
|
||||
// The operator's instance-wide availability is an operator decision.
|
||||
case "unavailable":
|
||||
return "operator_disabled";
|
||||
// The edge of the active grant: the record's configuration, as far as
|
||||
// this session may see it.
|
||||
case "not_granted":
|
||||
return "module_disabled";
|
||||
case "disabled":
|
||||
return AI_OPT_OUT_MODULES.has(key) ? "user_disabled" : "module_disabled";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The module layer for one capability. `all` needs every owning module and
|
||||
* reports the outermost reason among those that are off. `any` needs one: it
|
||||
* is available as soon as one path is, and otherwise reports the INNERMOST
|
||||
* reason, because moving that one layer is enough to open the capability.
|
||||
*/
|
||||
function modulesReason(
|
||||
def: AiCapabilityDefinition,
|
||||
moduleAccess: Readonly<Record<ModuleKey, ModuleAccessState>>,
|
||||
): AiUnavailableReason | null {
|
||||
const { mode, keys } = def.modules;
|
||||
if (keys.length === 0) return null;
|
||||
const reasons = keys.map((key) =>
|
||||
moduleReason(key, moduleAccess[key] ?? "unavailable"),
|
||||
);
|
||||
if (mode === "all") return outermost(reasons);
|
||||
if (reasons.some((reason) => reason === null)) return null;
|
||||
return innermost(reasons as AiUnavailableReason[]);
|
||||
}
|
||||
|
||||
/** The entries in the order this capability would pick from. */
|
||||
function orderedEntries(
|
||||
def: AiCapabilityDefinition,
|
||||
entries: readonly ProviderEntryPresence[],
|
||||
): readonly ProviderEntryPresence[] {
|
||||
if (def.providerOrder === "chain") return entries;
|
||||
// A stable sort keeps the person's own order within a rank.
|
||||
return [...entries].sort(
|
||||
(a, b) =>
|
||||
documentProviderRank(a.providerType) -
|
||||
documentProviderRank(b.providerType),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single provider a document read would use, or null when none can. The
|
||||
* first vision-capable entry wins; failing that, a text-only provider reads the
|
||||
* text the person's in-browser OCR produced, if they turned that on.
|
||||
*/
|
||||
function documentPick(
|
||||
def: AiCapabilityDefinition,
|
||||
provider: ProviderPresence,
|
||||
): ProviderEntryPresence | null {
|
||||
const ordered = orderedEntries(def, provider.entries);
|
||||
const vision = ordered.find((entry) => entry.vision);
|
||||
if (vision) return vision;
|
||||
if (provider.localOcrEnabled && ordered.length > 0) return ordered[0]!;
|
||||
return null;
|
||||
}
|
||||
|
||||
function providerReason(
|
||||
def: AiCapabilityDefinition,
|
||||
provider: ProviderPresence,
|
||||
): AiUnavailableReason | null {
|
||||
if (def.modality === "document") {
|
||||
return documentPick(def, provider) === null ? "no_provider" : null;
|
||||
}
|
||||
return provider.entries.length === 0 ? "no_provider" : null;
|
||||
}
|
||||
|
||||
/** Does serving this capability need a consent receipt? */
|
||||
function consentNeeded(
|
||||
def: AiCapabilityDefinition,
|
||||
provider: ProviderPresence,
|
||||
): boolean {
|
||||
if (def.consent.rule === "self-snapshot") {
|
||||
// A cascade may fall through to any entry, so one operator-held entry
|
||||
// anywhere in the chain is enough to need the receipt.
|
||||
return provider.entries.some((entry) =>
|
||||
OPERATOR_HELD_PROVIDER_TYPES.has(entry.providerType),
|
||||
);
|
||||
}
|
||||
if (def.modality === "document") {
|
||||
// Document reads call the one picked provider, no cascade.
|
||||
const pick = documentPick(def, provider);
|
||||
return pick !== null && pick.providerType !== LOCAL_PROVIDER_TYPE;
|
||||
}
|
||||
// Text under the document rule cascades like a snapshot does, so any entry
|
||||
// that leaves the machine needs the receipt.
|
||||
return provider.entries.some(
|
||||
(entry) => entry.providerType !== LOCAL_PROVIDER_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
function consentReason(
|
||||
def: AiCapabilityDefinition,
|
||||
provider: ProviderPresence,
|
||||
activeKinds: ReadonlySet<string>,
|
||||
): AiUnavailableReason | null {
|
||||
if (!consentNeeded(def, provider)) return null;
|
||||
return def.consent.kinds.some((kind) => activeKinds.has(kind))
|
||||
? null
|
||||
: "consent_required";
|
||||
}
|
||||
|
||||
function stateFor(reason: AiUnavailableReason | null): AiCapabilityState {
|
||||
if (reason === null) {
|
||||
return { available: true, reason: null, onDeviceAllowed: true };
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
reason,
|
||||
onDeviceAllowed: ON_DEVICE_ALLOWED_REASONS.has(reason),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve one capability. `null` inputs mean the loader failed. */
|
||||
export function resolveAiCapability(
|
||||
key: AiCapabilityKey,
|
||||
inputs: AiCapabilityInputs | null,
|
||||
): AiCapabilityState {
|
||||
if (inputs === null) return stateFor("check_failed");
|
||||
const def = AI_CAPABILITIES[key];
|
||||
const { switches } = inputs;
|
||||
return stateFor(
|
||||
outermost([
|
||||
!switches.enabled || !switches[def.operatorSwitch]
|
||||
? "operator_disabled"
|
||||
: null,
|
||||
inputs.providerWorkAdmitted ? null : "not_permitted_for_record",
|
||||
modulesReason(def, inputs.moduleAccess),
|
||||
providerReason(def, inputs.provider),
|
||||
consentReason(def, inputs.provider, inputs.activeConsentKinds),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve every capability. `null` inputs resolve all to `check_failed`. */
|
||||
export function resolveAiCapabilities(
|
||||
inputs: AiCapabilityInputs | null,
|
||||
): Record<AiCapabilityKey, AiCapabilityState> {
|
||||
const out = {} as Record<AiCapabilityKey, AiCapabilityState>;
|
||||
for (const key of AI_CAPABILITY_KEYS) {
|
||||
out[key] = resolveAiCapability(key, inputs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The account-level provider block. */
|
||||
export function resolveAiProviderState(
|
||||
inputs: AiCapabilityInputs | null,
|
||||
): AiProviderState {
|
||||
if (inputs === null) {
|
||||
return { configured: false, managedBy: null, canConfigure: false };
|
||||
}
|
||||
return {
|
||||
configured: inputs.provider.entries.length > 0,
|
||||
managedBy: inputs.provider.managedBy,
|
||||
canConfigure: inputs.recordKind === "self" && inputs.switches.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole `ai` block. */
|
||||
export function resolveAiBlock(
|
||||
inputs: AiCapabilityInputs | null,
|
||||
): AiCapabilities {
|
||||
return {
|
||||
capabilities: resolveAiCapabilities(inputs),
|
||||
provider: resolveAiProviderState(inputs),
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
* external egress from happening without an active receipt.
|
||||
*/
|
||||
import { isExternalDocumentEgress } from "@/lib/ai/consent-guard";
|
||||
import { documentProviderRank } from "@/lib/documents/provider-rank";
|
||||
import { RASTERIZATION_AVAILABLE } from "@/lib/documents/rasterize-pdf";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
@@ -37,38 +38,6 @@ import type {
|
||||
DocumentEgressClass,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Preference rank for a provider when the payload is a DOCUMENT. Lower wins.
|
||||
* Local keeps the document on the machine (rank 0); BYOK no-train API keys are
|
||||
* next (rank 1, including the user's own OpenAI-compatible gateway); the
|
||||
* operator's shared no-train key follows (rank 2); the
|
||||
* ChatGPT-subscription OAuth paths are LAST (rank 3) — the user's own `codex`
|
||||
* AND the operator's shared `admin-codex`, both train on consumer content by
|
||||
* default and cannot be verified opted-out from here.
|
||||
*/
|
||||
function documentProviderRank(providerType: string): number {
|
||||
switch (providerType) {
|
||||
case "local":
|
||||
return 0;
|
||||
case "openai":
|
||||
case "anthropic":
|
||||
case "admin-key":
|
||||
// The user's own gateway (LiteLLM / OpenRouter / vLLM) is a BYO endpoint
|
||||
// under their own contract. It may well be on their own network, but
|
||||
// HealthLog cannot tell that from the URL, so it ranks with the BYO API
|
||||
// keys rather than with `local` — the conservative side.
|
||||
case "openai-compatible":
|
||||
return 1;
|
||||
case "admin-openai":
|
||||
return 2;
|
||||
case "codex":
|
||||
case "admin-codex":
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a resolved chain for the document class: stable sort by
|
||||
* `documentProviderRank`, so within a rank tier the user's own chain order is
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The document-class provider preference, on its own so the capability
|
||||
* resolver can read it without the provider resolution around it.
|
||||
*
|
||||
* `provider-order.ts` sorts a resolved chain by it before a document read picks
|
||||
* a provider; the AI capability resolver sorts a presence-only chain by it to
|
||||
* know whether that pick would leave the machine. One rank, two readers, so
|
||||
* the answer published to a client and the provider a route picks cannot
|
||||
* disagree about which entry comes first.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Preference rank for a provider when the payload is a DOCUMENT. Lower wins.
|
||||
* Local keeps the document on the machine (rank 0); BYOK no-train API keys are
|
||||
* next (rank 1, including the user's own OpenAI-compatible gateway); the
|
||||
* operator's shared no-train key follows (rank 2); the
|
||||
* ChatGPT-subscription OAuth paths are LAST (rank 3) — the user's own `codex`
|
||||
* AND the operator's shared `admin-codex`, both train on consumer content by
|
||||
* default and cannot be verified opted-out from here.
|
||||
*/
|
||||
export function documentProviderRank(providerType: string): number {
|
||||
switch (providerType) {
|
||||
case "local":
|
||||
return 0;
|
||||
case "openai":
|
||||
case "anthropic":
|
||||
case "admin-key":
|
||||
// The user's own gateway (LiteLLM / OpenRouter / vLLM) is a BYO endpoint
|
||||
// under their own contract. It may well be on their own network, but
|
||||
// HealthLog cannot tell that from the URL, so it ranks with the BYO API
|
||||
// keys rather than with `local` — the conservative side.
|
||||
case "openai-compatible":
|
||||
return 1;
|
||||
case "admin-openai":
|
||||
return 2;
|
||||
case "codex":
|
||||
case "admin-codex":
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user