mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(coach): lay down the reasoning, trail and memory contract
Add the shared contract the reasoning release builds on, with no change to how a turn runs today. - Schema: operator reasoning controls on app_settings (0376), the encrypted trail text on coach_messages (0377), and source, last_used_at and source_message_id on coach_facts (0378). - Provider contract: CompletionParams.reasoning and onReasoning, CompletionResult.reasoning and providerState, AiMessage.providerState. - Reasoning levels, thinking budgets and the background-job table in src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent meaning medium, salvaged field by field like every other pref. - Stream: activity, memoryNote and planProposal frames, done.stop and done.withheldResults, result.interim, a compare chart kind, eight table refs, three more clarification kinds with an assumed choice, the change_assumption chip, and assumptions, activity, stop, memoryNote and planProposal on the provenance. Zod mirror, type parity and the native-client key rule cover every new frame. - Chat request: memoryDecision and planDecision, parsed and not yet acted on. - Stub contracts for the memory functions and the activity recorder, inert until their implementations land. - Backup, restore and rotation carry the new columns; a fact's message reference is resolved against the restored messages like its conversation reference. - Message keys for the trail, memory, plans, assumptions, replies, interim tables, comparison charts and reasoning settings in all seven locales, each reachable from a catalog and checked both ways.
This commit is contained in:
1 parent
f269100b01
commit
210becb28b
38 files changed
+3308
-57
No files matched your search
@@ -10,7 +10,8 @@
|
||||
* shape would make the whole frame fail to decode, and a new frame that
|
||||
* reused one with the same shape would leak a value into whatever the client
|
||||
* does with that key. So a new frame names its payload with a key of its own
|
||||
* (`step`, `result`, `followUps`, `clarification`).
|
||||
* (`step`, `result`, `followUps`, `clarification`, and since v1.41
|
||||
* `activity`, `memoryNote` with `note`, `planProposal` with `proposal`).
|
||||
*
|
||||
* Reads the frame list from the Zod mirror (`stream-events.ts`), which a
|
||||
* type test holds equal to `CoachStreamEvent`, so a frame added to the
|
||||
@@ -81,6 +82,9 @@ describe("Coach stream frames stay decodable by the shipped native client", () =
|
||||
"result",
|
||||
"followUps",
|
||||
"clarification",
|
||||
"activity",
|
||||
"memoryNote",
|
||||
"planProposal",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
|
||||
@@ -122,6 +122,9 @@ export function CoachResultChart({
|
||||
if (spec.kind === "histogram") {
|
||||
return <HistogramChart result={result} spec={spec} label={label} />;
|
||||
}
|
||||
// v1.41 — the contract is in place; no table carries this kind yet, and the
|
||||
// table view stays available until the chart lands.
|
||||
if (spec.kind === "compare") return null;
|
||||
if (spec.kind === "bar" && spec.orientation === "horizontal") {
|
||||
return (
|
||||
<CategoryBarChart
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The v1.41 message keys and the seven bundles agree, both ways.
|
||||
*
|
||||
* The trail, memory, plan, reasoning and reply keys were laid down before
|
||||
* the code that renders them, so the catalogs in the contract modules are
|
||||
* their only reference until then. Forward: every key a catalog can produce
|
||||
* resolves in every locale, with the same placeholders as English. Reverse:
|
||||
* every leaf under the namespaces these catalogs own is one they can
|
||||
* produce, so a string cannot sit in the bundles with nothing able to reach
|
||||
* it.
|
||||
*
|
||||
* Checked by breaking it: deleting `insights.coach.activity.answer` from
|
||||
* `pl.json` fails the forward test by locale and key; renaming `{fact}` in
|
||||
* the German `insights.coach.memory.saved` fails the placeholder test; adding
|
||||
* an unused leaf under `insights.coach.reasoning` fails the reverse test.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
COACH_ACTIVITY_KEYS,
|
||||
COACH_ACTIVITY_STOP_KEYS,
|
||||
activityAreasKey,
|
||||
activityDigestDoneKey,
|
||||
activityDigestKey,
|
||||
activityMemoryKey,
|
||||
activitySummaryKey,
|
||||
} from "@/lib/ai/coach/activity/contract";
|
||||
import {
|
||||
COACH_ASSUMPTION_KEYS,
|
||||
COACH_CHART_COMPARE_KEYS,
|
||||
COACH_SUGGESTED_REPLIES_KEYS,
|
||||
interimLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import {
|
||||
COACH_MEMORY_KEYS,
|
||||
COACH_MEMORY_LIST_KEYS,
|
||||
COACH_PLAN_KEYS,
|
||||
} from "@/lib/ai/coach/memory/contract";
|
||||
import {
|
||||
REASONING_ADMIN_KEYS,
|
||||
REASONING_LEVEL_LABEL_KEYS,
|
||||
REASONING_SETTING_KEYS,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
const ROOT = join(__dirname, "../../../../..");
|
||||
|
||||
function strings(locale: Locale): Map<string, unknown> {
|
||||
const out = new Map<string, unknown>();
|
||||
const walk = (node: unknown, prefix: string) => {
|
||||
if (node && typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
walk(value, prefix ? `${prefix}.${key}` : key);
|
||||
}
|
||||
} else {
|
||||
out.set(prefix, node);
|
||||
}
|
||||
};
|
||||
walk(
|
||||
JSON.parse(
|
||||
readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
|
||||
) as unknown,
|
||||
"",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Counts that land on every plural tier some locale uses. */
|
||||
const COUNTS = [0, 1, 2, 3, 5, 12, 22];
|
||||
|
||||
function catalogKeys(): Set<string> {
|
||||
const keys = new Set<string>([
|
||||
...Object.values(COACH_ACTIVITY_KEYS),
|
||||
...Object.values(COACH_ACTIVITY_STOP_KEYS),
|
||||
...Object.values(COACH_MEMORY_KEYS),
|
||||
...Object.values(COACH_PLAN_KEYS),
|
||||
...Object.values(COACH_MEMORY_LIST_KEYS),
|
||||
...Object.values(COACH_ASSUMPTION_KEYS),
|
||||
...Object.values(COACH_SUGGESTED_REPLIES_KEYS),
|
||||
...Object.values(COACH_CHART_COMPARE_KEYS),
|
||||
...Object.values(REASONING_LEVEL_LABEL_KEYS),
|
||||
...Object.values(REASONING_SETTING_KEYS),
|
||||
...Object.values(REASONING_ADMIN_KEYS),
|
||||
]);
|
||||
for (const locale of locales) {
|
||||
for (const count of COUNTS) {
|
||||
keys.add(activityDigestKey(count, locale));
|
||||
keys.add(activityDigestDoneKey(count, locale));
|
||||
keys.add(activityAreasKey(count, locale));
|
||||
keys.add(activityMemoryKey(count, locale));
|
||||
keys.add(activitySummaryKey(count, locale));
|
||||
keys.add(interimLabelKey(count, locale));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const OWNED = [
|
||||
"insights.coach.activity",
|
||||
"insights.coach.memory",
|
||||
"insights.coach.plan",
|
||||
"insights.coach.assumption",
|
||||
"insights.coach.suggestedReplies",
|
||||
"insights.coach.interim",
|
||||
"insights.coach.chart",
|
||||
"insights.coach.reasoning",
|
||||
"settings.coach.memory",
|
||||
"admin.assistant.reasoning",
|
||||
];
|
||||
|
||||
const placeholders = (text: string) =>
|
||||
[...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1]).sort();
|
||||
|
||||
describe("v1.41 contract keys", () => {
|
||||
const keys = catalogKeys();
|
||||
const en = strings("en");
|
||||
|
||||
it("produces the whole catalog", () => {
|
||||
// Every key laid down for the release but the `change_assumption` chip
|
||||
// label, which the dialog catalog's own test covers.
|
||||
expect(keys.size).toBe(57);
|
||||
});
|
||||
|
||||
it.each(locales)("every catalog key resolves in %s", (locale) => {
|
||||
const bundle = strings(locale);
|
||||
const missing = [...keys].filter(
|
||||
(key) => typeof bundle.get(key) !== "string" || bundle.get(key) === "",
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(locales)("%s keeps the English placeholders", (locale) => {
|
||||
const bundle = strings(locale);
|
||||
const drifted = [...keys].filter(
|
||||
(key) =>
|
||||
placeholders(String(bundle.get(key))).join() !==
|
||||
placeholders(String(en.get(key))).join(),
|
||||
);
|
||||
expect(drifted).toEqual([]);
|
||||
});
|
||||
|
||||
it("every leaf under the owned namespaces is reachable from a catalog", () => {
|
||||
const owned = [...en.keys()].filter((key) =>
|
||||
OWNED.some((ns) => key.startsWith(`${ns}.`)),
|
||||
);
|
||||
expect(owned.length).toBeGreaterThan(40);
|
||||
expect(owned.filter((key) => !keys.has(key))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,15 @@
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTIVITY_MAX_ENTRIES } from "../activity/contract";
|
||||
import {
|
||||
coachActivityMetaSchema,
|
||||
coachClarificationSchema,
|
||||
coachFollowUpSchema,
|
||||
coachMemoryNoteMetaSchema,
|
||||
coachResultMetaSchema,
|
||||
coachStepSchema,
|
||||
coachTrailSchema,
|
||||
} from "../stream-events";
|
||||
|
||||
const STEP = {
|
||||
@@ -107,3 +111,86 @@ describe("stored dialog ids", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.41 ids", () => {
|
||||
const ACTIVITY = {
|
||||
id: "a1",
|
||||
phase: "fetch",
|
||||
status: "done",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.fetching",
|
||||
label: "Fetching blood pressure, last 30 days…",
|
||||
stepRef: "s7",
|
||||
};
|
||||
|
||||
it("takes eight tables a message and no ninth", () => {
|
||||
expect(
|
||||
coachResultMetaSchema.safeParse({ ...META, ref: "r8" }).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
coachResultMetaSchema.safeParse({ ...META, ref: "r9" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
coachFollowUpSchema.safeParse({
|
||||
...CHIP,
|
||||
anchor: { ref: "r8", domain: "bp" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("numbers trail entries a1 up to the cap", () => {
|
||||
expect(coachActivityMetaSchema.safeParse(ACTIVITY).success).toBe(true);
|
||||
expect(
|
||||
coachActivityMetaSchema.safeParse({
|
||||
...ACTIVITY,
|
||||
id: `a${ACTIVITY_MAX_ENTRIES}`,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
coachActivityMetaSchema.safeParse({
|
||||
...ACTIVITY,
|
||||
id: `a${ACTIVITY_MAX_ENTRIES + 1}`,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses anything that could carry text into a prompt", () => {
|
||||
expect(
|
||||
coachActivityMetaSchema.safeParse({ ...ACTIVITY, id: PLANT }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
coachActivityMetaSchema.safeParse({ ...ACTIVITY, stepRef: PLANT })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
coachTrailSchema.safeParse({ entries: [{ id: PLANT, title: "t" }] })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
coachMemoryNoteMetaSchema.safeParse({
|
||||
proposal: false,
|
||||
factId: PLANT,
|
||||
category: "goal",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
coachClarificationSchema.safeParse({
|
||||
kind: "goal",
|
||||
freeText: true,
|
||||
assumption: PLANT,
|
||||
choices: [
|
||||
{ id: "c1", labelKey: "k", label: "l", value: { goal: "plan1" } },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
coachClarificationSchema.safeParse({
|
||||
kind: "goal",
|
||||
freeText: true,
|
||||
choices: [
|
||||
{ id: "c1", labelKey: "k", label: "l", value: { goal: PLANT } },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -15,26 +15,44 @@ import { describe, expectTypeOf, it } from "vitest";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type {
|
||||
CoachActivity,
|
||||
CoachActivityMeta,
|
||||
CoachAssumption,
|
||||
CoachClarification,
|
||||
CoachFollowUp,
|
||||
CoachMemoryNote,
|
||||
CoachMemoryNoteMeta,
|
||||
CoachPlanProposal,
|
||||
CoachPlanProposalMeta,
|
||||
CoachMethod,
|
||||
CoachProvenance,
|
||||
CoachResultEntry,
|
||||
CoachResultMeta,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
CoachStop,
|
||||
CoachStreamEvent,
|
||||
CoachTrail,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
coachActivityMetaSchema,
|
||||
coachActivitySchema,
|
||||
coachAssumptionSchema,
|
||||
coachClarificationSchema,
|
||||
coachFollowUpSchema,
|
||||
coachMemoryNoteMetaSchema,
|
||||
coachMemoryNoteSchema,
|
||||
coachPlanProposalMetaSchema,
|
||||
coachPlanProposalSchema,
|
||||
coachMethodSchema,
|
||||
coachProvenanceSchema,
|
||||
coachResultEntrySchema,
|
||||
coachResultMetaSchema,
|
||||
coachResultTableSchema,
|
||||
coachStepSchema,
|
||||
coachStopSchema,
|
||||
coachStreamEventSchema,
|
||||
coachTrailSchema,
|
||||
} from "@/lib/ai/coach/stream-events";
|
||||
|
||||
describe("Coach wire: Zod mirror equals the TypeScript contract", () => {
|
||||
@@ -68,4 +86,32 @@ describe("Coach wire: Zod mirror equals the TypeScript contract", () => {
|
||||
z.infer<typeof coachProvenanceSchema>
|
||||
>().toEqualTypeOf<CoachProvenance>();
|
||||
});
|
||||
|
||||
it("the v1.41 trail, memory and plan shapes", () => {
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachActivitySchema>
|
||||
>().toEqualTypeOf<CoachActivity>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachActivityMetaSchema>
|
||||
>().toEqualTypeOf<CoachActivityMeta>();
|
||||
expectTypeOf<z.infer<typeof coachStopSchema>>().toEqualTypeOf<CoachStop>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachAssumptionSchema>
|
||||
>().toEqualTypeOf<CoachAssumption>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachMemoryNoteSchema>
|
||||
>().toEqualTypeOf<CoachMemoryNote>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachMemoryNoteMetaSchema>
|
||||
>().toEqualTypeOf<CoachMemoryNoteMeta>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachPlanProposalSchema>
|
||||
>().toEqualTypeOf<CoachPlanProposal>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachPlanProposalMetaSchema>
|
||||
>().toEqualTypeOf<CoachPlanProposalMeta>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachTrailSchema>
|
||||
>().toEqualTypeOf<CoachTrail>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ACTIVITY_TEXT_MAX_CHARS,
|
||||
ACTIVITY_TITLE_MAX_CHARS,
|
||||
TRAIL_MAX_BYTES,
|
||||
activitySummaryKey,
|
||||
createNoopActivityRecorder,
|
||||
} from "../contract";
|
||||
|
||||
describe("activity contract", () => {
|
||||
it("the no-op recorder sends and stores nothing", () => {
|
||||
const recorder = createNoopActivityRecorder();
|
||||
const id = recorder.start({
|
||||
phase: "thinking",
|
||||
round: 1,
|
||||
labelKey: "insights.coach.activity.thinking",
|
||||
label: "Thinking…",
|
||||
});
|
||||
recorder.update(id, { title: "**Checking the trend**" });
|
||||
recorder.finish(id, "done", { text: "Looked at the last month." });
|
||||
expect(recorder.meta()).toEqual([]);
|
||||
expect(recorder.trail()).toBeNull();
|
||||
});
|
||||
|
||||
it("a full trail of model text fits its stored bound", () => {
|
||||
// 99 entries can never all carry text: the bound is what a turn stores,
|
||||
// and the recorder trims to it. A dozen rounds of full text must fit.
|
||||
const twelveRounds = JSON.stringify({
|
||||
entries: Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `a${i + 1}`,
|
||||
title: "t".repeat(ACTIVITY_TITLE_MAX_CHARS),
|
||||
text: "x".repeat(ACTIVITY_TEXT_MAX_CHARS),
|
||||
})),
|
||||
});
|
||||
expect(new TextEncoder().encode(twelveRounds).byteLength).toBeLessThan(
|
||||
TRAIL_MAX_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing about lookups on a turn that made none", () => {
|
||||
expect(activitySummaryKey(0, "en")).toBe(
|
||||
"insights.coach.activity.summaryNoLookups",
|
||||
);
|
||||
expect(activitySummaryKey(1, "en")).toBe(
|
||||
"insights.coach.activity.summaryOne",
|
||||
);
|
||||
expect(activitySummaryKey(3, "pl")).toBe(
|
||||
"insights.coach.activity.summaryFew",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* v1.41 — the contract of a turn's live trail.
|
||||
*
|
||||
* The loop reports what it is doing through an {@link ActivityRecorder}: it
|
||||
* opens an entry before each phase starts (the thinking round before the
|
||||
* provider call, each fetch before its tool runs), updates it while it runs
|
||||
* (a reasoning title arriving, a count) and closes it. The recorder sends the
|
||||
* `activity` frames, screens model text before it goes anywhere, and at the
|
||||
* end hands back the plaintext metadata for `metricSource.activity` and the
|
||||
* model text for `coach_messages.trail_encrypted`.
|
||||
*
|
||||
* Labels are always catalog text rendered on the server; model text only ever
|
||||
* rides `title` and `text`. A `checkpoint` entry (assistant text that came
|
||||
* beside tool calls) is labelled with the thinking key and carries the
|
||||
* screened sentence as its title.
|
||||
*
|
||||
* The no-op recorder is what every caller holds until the real one is wired:
|
||||
* it sends nothing and stores nothing, so a turn behaves exactly as before.
|
||||
*
|
||||
* Client-safe: no server import.
|
||||
*/
|
||||
import type {
|
||||
CoachActivityMeta,
|
||||
CoachActivityPhase,
|
||||
CoachStepStatus,
|
||||
CoachStopReason,
|
||||
CoachTrail,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
|
||||
/** The stored trail text of one message, as UTF-8 JSON, before encryption. */
|
||||
export const TRAIL_MAX_BYTES = 16 * 1024;
|
||||
|
||||
/** A reasoning title or checkpoint sentence, after screening. */
|
||||
export const ACTIVITY_TITLE_MAX_CHARS = 80;
|
||||
|
||||
/** A reasoning summary per round, after screening. */
|
||||
export const ACTIVITY_TEXT_MAX_CHARS = 400;
|
||||
|
||||
/** `a1`..`a99`: the 100th entry of a turn is not recorded. */
|
||||
export const ACTIVITY_MAX_ENTRIES = 99;
|
||||
|
||||
export interface ActivityStart {
|
||||
phase: CoachActivityPhase;
|
||||
round: number;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
stepRef?: string;
|
||||
count?: number;
|
||||
stop?: CoachStopReason;
|
||||
}
|
||||
|
||||
export interface ActivityPatch {
|
||||
/** Model text; screened by the recorder, dropped when it fails. */
|
||||
title?: string;
|
||||
/** Model text; screened by the recorder, dropped when it fails. */
|
||||
text?: string;
|
||||
/** A new catalog label (the digest count once the round has settled). */
|
||||
labelKey?: string;
|
||||
label?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface ActivityRecorder {
|
||||
/** Opens an entry, sends its `running` frame and returns its id. */
|
||||
start(entry: ActivityStart): string;
|
||||
/** Updates an open entry and sends the frame again. */
|
||||
update(id: string, patch: ActivityPatch): void;
|
||||
/** Closes an entry with its final status and duration. */
|
||||
finish(
|
||||
id: string,
|
||||
status: Exclude<CoachStepStatus, "running">,
|
||||
patch?: ActivityPatch,
|
||||
): void;
|
||||
/** The entries so far, for `metricSource.activity`. */
|
||||
meta(): CoachActivityMeta[];
|
||||
/** The screened model text so far, or null when there is none. */
|
||||
trail(): CoachTrail | null;
|
||||
}
|
||||
|
||||
/** Sends nothing, stores nothing: a turn runs exactly as it did before. */
|
||||
export function createNoopActivityRecorder(): ActivityRecorder {
|
||||
return {
|
||||
start: () => "a1",
|
||||
update: () => undefined,
|
||||
finish: () => undefined,
|
||||
meta: () => [],
|
||||
trail: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Message keys ────────────────────────────────────────────────────────────
|
||||
|
||||
/** The labels with no count. */
|
||||
export const COACH_ACTIVITY_KEYS = {
|
||||
thinking: "insights.coach.activity.thinking",
|
||||
fetching: "insights.coach.activity.fetching",
|
||||
remember: "insights.coach.activity.remember",
|
||||
plan: "insights.coach.activity.plan",
|
||||
asking: "insights.coach.activity.asking",
|
||||
answer: "insights.coach.activity.answer",
|
||||
} as const;
|
||||
|
||||
/** The `stop` entry's label, by reason. */
|
||||
export const COACH_ACTIVITY_STOP_KEYS: Readonly<
|
||||
Record<CoachStopReason, string>
|
||||
> = {
|
||||
budget: "insights.coach.activity.stop.budget",
|
||||
time: "insights.coach.activity.stop.time",
|
||||
cap: "insights.coach.activity.stop.cap",
|
||||
no_progress: "insights.coach.activity.stop.noProgress",
|
||||
};
|
||||
|
||||
/** "Summarising N readings…", while a round's results are folded in. */
|
||||
export function activityDigestKey(count: number, locale: Locale): string {
|
||||
return pluralKey("insights.coach.activity.digest", count, locale);
|
||||
}
|
||||
|
||||
/** "N readings from {areas}", once the round has settled. */
|
||||
export function activityDigestDoneKey(count: number, locale: Locale): string {
|
||||
return pluralKey("insights.coach.activity.digestDone", count, locale);
|
||||
}
|
||||
|
||||
/** "N areas", the `{areas}` of the settled digest. */
|
||||
export function activityAreasKey(count: number, locale: Locale): string {
|
||||
return pluralKey("insights.coach.activity.areas", count, locale);
|
||||
}
|
||||
|
||||
/** "Recalls N things you told it". */
|
||||
export function activityMemoryKey(count: number, locale: Locale): string {
|
||||
return pluralKey("insights.coach.activity.memory", count, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-line summary once the turn is done: "Thought it through, N lookups,
|
||||
* S s", or the seconds alone on a turn that looked nothing up.
|
||||
*/
|
||||
export function activitySummaryKey(lookups: number, locale: Locale): string {
|
||||
return lookups === 0
|
||||
? "insights.coach.activity.summaryNoLookups"
|
||||
: pluralKey("insights.coach.activity.summary", lookups, locale);
|
||||
}
|
||||
@@ -231,6 +231,7 @@ export const COACH_FOLLOW_UP_KEYS: Readonly<Record<CoachFollowUpKind, string>> =
|
||||
as_table: "coach.followUp.asTable",
|
||||
related_metric: "coach.followUp.relatedMetric",
|
||||
continue: "coach.followUp.continue",
|
||||
change_assumption: "coach.followUp.changeAssumption",
|
||||
};
|
||||
|
||||
export const COACH_FOLLOW_UP_UI_KEYS = {
|
||||
@@ -253,6 +254,29 @@ export function clarifyWindowLabelKey(window: CoachScopeWindow): string {
|
||||
return `coach.clarify.window.${window}`;
|
||||
}
|
||||
|
||||
// ── Replies, assumptions, interim tables (v1.41) ──────────────────────────
|
||||
|
||||
/** The group of reply pills under the latest answer. */
|
||||
export const COACH_SUGGESTED_REPLIES_KEYS = {
|
||||
groupLabel: "insights.coach.suggestedReplies.groupLabel",
|
||||
} as const;
|
||||
|
||||
/** "Assumed: last 30 days. Change". */
|
||||
export const COACH_ASSUMPTION_KEYS = {
|
||||
line: "insights.coach.assumption.line",
|
||||
change: "insights.coach.assumption.change",
|
||||
} as const;
|
||||
|
||||
/** The one-line preview of a table read while the turn still runs. */
|
||||
export function interimLabelKey(count: number, locale: Locale): string {
|
||||
return pluralKey("insights.coach.interim.label", count, locale);
|
||||
}
|
||||
|
||||
/** The legend of a chart comparing two periods ("{a} vs {b}"). */
|
||||
export const COACH_CHART_COMPARE_KEYS = {
|
||||
periods: "insights.coach.chart.compare.periods",
|
||||
} as const;
|
||||
|
||||
// ── Reuse ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The prose of a turn answered from a stored table without a model call. */
|
||||
|
||||
@@ -129,6 +129,9 @@ export function followUpContextHint(
|
||||
case "continue":
|
||||
// Answered with its own context lines (`continue.ts`).
|
||||
return null;
|
||||
case "change_assumption":
|
||||
// v1.41 — the contract is in place; no chip of this kind is offered yet.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { COACH_MEMORY_CATEGORIES } from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
COACH_FACT_SOURCES,
|
||||
HEALTH_MEMORY_CATEGORIES,
|
||||
MEMORY_BLOCK_MAX_CHARS,
|
||||
buildMemoryContextBlock,
|
||||
buildPlanProgressLines,
|
||||
decideFactProposal,
|
||||
decidePlanProposal,
|
||||
proposePlanFromTool,
|
||||
rememberFactFromTool,
|
||||
} from "../contract";
|
||||
|
||||
const IDS = { userId: "u1", conversationId: "c1" };
|
||||
|
||||
describe("memory contract stubs", () => {
|
||||
it("build no block, write nothing and decide nothing", async () => {
|
||||
await expect(
|
||||
buildMemoryContextBlock({ ...IDS, locale: "en" }),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
rememberFactFromTool({
|
||||
...IDS,
|
||||
userMessage: "I want to reach 75 kg by December",
|
||||
call: { category: "goal", fact: "Wants 75 kg by December", why: "w" },
|
||||
}),
|
||||
).resolves.toEqual({ kind: "declined", reason: "unavailable" });
|
||||
await expect(
|
||||
decideFactProposal({
|
||||
...IDS,
|
||||
messageId: "m1",
|
||||
proposalId: "p1",
|
||||
accept: true,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "stale" });
|
||||
await expect(
|
||||
proposePlanFromTool({
|
||||
...IDS,
|
||||
call: {
|
||||
metric: "WEIGHT",
|
||||
ifCue: "after dinner",
|
||||
thenAction: "walk",
|
||||
reviewInDays: 14,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ kind: "declined", reason: "unavailable" });
|
||||
await expect(
|
||||
decidePlanProposal({
|
||||
...IDS,
|
||||
messageId: "m1",
|
||||
planId: "p1",
|
||||
accept: true,
|
||||
}),
|
||||
).resolves.toEqual({ kind: "stale" });
|
||||
await expect(buildPlanProgressLines("u1")).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory contract constants", () => {
|
||||
it("health categories are a subset of the memory categories", () => {
|
||||
for (const category of HEALTH_MEMORY_CATEGORIES) {
|
||||
expect(COACH_MEMORY_CATEGORIES).toContain(category);
|
||||
}
|
||||
expect([...HEALTH_MEMORY_CATEGORIES].sort()).toEqual([
|
||||
"condition",
|
||||
"constraint",
|
||||
"medication",
|
||||
]);
|
||||
});
|
||||
|
||||
it("the column default is one of the sources", () => {
|
||||
const schema = readFileSync(
|
||||
join(__dirname, "../../../../../../prisma/schema.prisma"),
|
||||
"utf8",
|
||||
);
|
||||
const model = schema.slice(schema.indexOf("model CoachFact {"));
|
||||
const match = /source\s+String\s+@default\("(\w+)"\)/.exec(model);
|
||||
expect(match?.[1]).toBeDefined();
|
||||
expect(COACH_FACT_SOURCES).toContain(match?.[1]);
|
||||
});
|
||||
|
||||
it("keeps the memory block small next to the inventory", () => {
|
||||
expect(MEMORY_BLOCK_MAX_CHARS).toBeLessThanOrEqual(1_500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* v1.41 — the contract of the Coach's memory inside a turn.
|
||||
*
|
||||
* The loop and the chat route reach facts and plans only through these
|
||||
* functions: the memory block that goes into every turn, the `remember_fact`
|
||||
* and `propose_plan` tools, the two decisions a person answers with a tap
|
||||
* (`memoryDecision`, `planDecision` on the chat request), and the progress
|
||||
* lines the daily briefing quotes.
|
||||
*
|
||||
* Until the implementations land, every function is a stub that answers "not
|
||||
* available" and touches nothing: no block is built, no fact is written, no
|
||||
* plan changes state. A turn behaves exactly as it did before.
|
||||
*
|
||||
* Rules every implementation keeps, so a caller can rely on them:
|
||||
* - health facts (`condition`, `constraint`, `medication`) are only ever
|
||||
* proposed, never saved without the person's tap;
|
||||
* - a fact comes from the person's current message, never from a document or
|
||||
* a quote;
|
||||
* - the block is built only after the turn has passed its egress check, and
|
||||
* never for a read-only sharing viewer;
|
||||
* - fact and plan text never reaches a log or `annotate()`; counts and ids do.
|
||||
*/
|
||||
import type {
|
||||
CoachMemoryCategory,
|
||||
CoachMemoryNote,
|
||||
CoachPlanProposal,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
/** Who put a fact into `coach_facts.source`. */
|
||||
export const COACH_FACT_SOURCES = [
|
||||
"user",
|
||||
"coach",
|
||||
"extracted",
|
||||
"pattern",
|
||||
] as const;
|
||||
|
||||
/** The categories that are proposed and never saved without a tap. */
|
||||
export const HEALTH_MEMORY_CATEGORIES: ReadonlySet<CoachMemoryCategory> =
|
||||
new Set(["condition", "constraint", "medication"]);
|
||||
|
||||
/** A remembered fact, as `remember_fact` may write it. */
|
||||
export const REMEMBER_FACT_MAX_CHARS = 160;
|
||||
|
||||
/** The memory block in a turn's context, never trimmed before the inventory. */
|
||||
export const MEMORY_BLOCK_MAX_CHARS = 1_500;
|
||||
|
||||
/** A proposed plan's review window, in days. */
|
||||
export const PLAN_REVIEW_DAYS = { min: 7, max: 56 } as const;
|
||||
|
||||
// ── The memory block ───────────────────────────────────────────────────────
|
||||
|
||||
export interface MemoryContextBlock {
|
||||
/** The fenced `WHAT YOU KNOW ABOUT THIS PERSON` section; data, not orders. */
|
||||
text: string;
|
||||
/** The facts in the block, for `last_used_at` and the `memory` entry. */
|
||||
factIds: string[];
|
||||
planIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts and active plans a turn starts with, or null when there are none
|
||||
* or memory is unavailable. The caller has already passed the egress check.
|
||||
*/
|
||||
export async function buildMemoryContextBlock(_args: {
|
||||
userId: string;
|
||||
conversationId: string | undefined;
|
||||
locale: string;
|
||||
}): Promise<MemoryContextBlock | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── remember_fact ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface RememberFactCall {
|
||||
category: CoachMemoryCategory;
|
||||
/** At most {@link REMEMBER_FACT_MAX_CHARS}. */
|
||||
fact: string;
|
||||
why: string;
|
||||
}
|
||||
|
||||
export type RememberFactOutcome =
|
||||
| { kind: "saved"; note: CoachMemoryNote }
|
||||
| { kind: "proposed"; note: CoachMemoryNote }
|
||||
| {
|
||||
kind: "declined";
|
||||
reason:
|
||||
| "duplicate"
|
||||
| "not_from_message"
|
||||
| "one_per_answer"
|
||||
| "invalid"
|
||||
| "unavailable";
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a `remember_fact` call. `userMessage` is the person's current message,
|
||||
* the only text a fact may come from.
|
||||
*/
|
||||
export async function rememberFactFromTool(_args: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
userMessage: string;
|
||||
call: RememberFactCall;
|
||||
}): Promise<RememberFactOutcome> {
|
||||
return { kind: "declined", reason: "unavailable" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a fact proposal. The fact is read from the trail stored on
|
||||
* `messageId`, never from the request.
|
||||
*/
|
||||
export async function decideFactProposal(_args: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
proposalId: string;
|
||||
accept: boolean;
|
||||
}): Promise<
|
||||
{ kind: "saved"; factId: string } | { kind: "declined" } | { kind: "stale" }
|
||||
> {
|
||||
return { kind: "stale" };
|
||||
}
|
||||
|
||||
// ── propose_plan ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProposePlanCall {
|
||||
metric: string;
|
||||
target?: string;
|
||||
ifCue: string;
|
||||
thenAction: string;
|
||||
/** {@link PLAN_REVIEW_DAYS}. */
|
||||
reviewInDays: number;
|
||||
}
|
||||
|
||||
export type ProposePlanOutcome =
|
||||
| { kind: "proposed"; proposal: CoachPlanProposal }
|
||||
| {
|
||||
kind: "declined";
|
||||
reason: "one_per_answer" | "too_many_open" | "invalid" | "unavailable";
|
||||
};
|
||||
|
||||
/** Runs a `propose_plan` call: writes the plan as `proposed`. */
|
||||
export async function proposePlanFromTool(_args: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
call: ProposePlanCall;
|
||||
}): Promise<ProposePlanOutcome> {
|
||||
return { kind: "declined", reason: "unavailable" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a plan proposal: `active` on accept, `abandoned` on decline. Only a
|
||||
* plan the assistant message proposed, and only the person's own.
|
||||
*/
|
||||
export async function decidePlanProposal(_args: {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
planId: string;
|
||||
accept: boolean;
|
||||
}): Promise<
|
||||
| { kind: "activated"; reviewInDays: number }
|
||||
| { kind: "abandoned" }
|
||||
| { kind: "stale" }
|
||||
> {
|
||||
return { kind: "stale" };
|
||||
}
|
||||
|
||||
// ── The briefing ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One server-computed progress sentence per active plan, at most two, for
|
||||
* the daily briefing prompt. The model may quote one; it may not invent one.
|
||||
*/
|
||||
export async function buildPlanProgressLines(
|
||||
_userId: string,
|
||||
): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Message keys ────────────────────────────────────────────────────────────
|
||||
|
||||
/** The memory note under an answer, and its two replies. */
|
||||
export const COACH_MEMORY_KEYS = {
|
||||
saved: "insights.coach.memory.saved",
|
||||
undo: "insights.coach.memory.undo",
|
||||
accept: "insights.coach.memory.accept",
|
||||
decline: "insights.coach.memory.decline",
|
||||
confirmed: "insights.coach.memory.confirmed",
|
||||
} as const;
|
||||
|
||||
/** A plan proposal's two replies, and the line a decision turn answers with. */
|
||||
export const COACH_PLAN_KEYS = {
|
||||
accept: "insights.coach.plan.accept",
|
||||
decline: "insights.coach.plan.decline",
|
||||
confirmed: "insights.coach.plan.confirmed",
|
||||
} as const;
|
||||
|
||||
/** The memory list in settings, and the link to it from the Coach. */
|
||||
export const COACH_MEMORY_LIST_KEYS = {
|
||||
link: "insights.coach.memoryLink",
|
||||
edit: "settings.coach.memory.edit",
|
||||
sourceUser: "settings.coach.memory.source.user",
|
||||
sourceCoach: "settings.coach.memory.source.coach",
|
||||
categoryMedication: "settings.ai.coachMemory.categoryMedication",
|
||||
} as const;
|
||||
@@ -14,12 +14,21 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AI_UNAVAILABLE_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
ACTIVITY_TEXT_MAX_CHARS,
|
||||
ACTIVITY_TITLE_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/activity/contract";
|
||||
import {
|
||||
PLAN_REVIEW_DAYS,
|
||||
REMEMBER_FACT_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/memory/contract";
|
||||
import {
|
||||
SUGGESTED_ACTION_TYPES,
|
||||
type CheckupIntervalId,
|
||||
} from "@/lib/ai/coach/suggest-action";
|
||||
import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions";
|
||||
import {
|
||||
COACH_MEMORY_CATEGORIES,
|
||||
coachKeyValueSchema,
|
||||
coachScopeSourceSchema,
|
||||
coachScopeWindowSchema,
|
||||
@@ -55,8 +64,8 @@ const coachResultPeriodSchema = z.enum(["current", "previous", "yearAgo"]);
|
||||
// them are written back into the model's context (`m<k>.r<n>` lines, the
|
||||
// "keep looking" fetched list). Each is held to its exact server-minted
|
||||
// shape, so a stored row edited by hand cannot carry text into a prompt.
|
||||
/** A table of a message: `r1`..`r6`. */
|
||||
const resultRefSchema = z.string().regex(/^r[1-6]$/);
|
||||
/** A table of a message: `r1`..`r8`. */
|
||||
const resultRefSchema = z.string().regex(/^r[1-8]$/);
|
||||
/** A server-minted row id (cuid). */
|
||||
const messageIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
|
||||
|
||||
@@ -67,7 +76,7 @@ export const coachStepSchema = z
|
||||
id: z
|
||||
.string()
|
||||
.regex(/^s[1-9]\d{0,2}$/)
|
||||
.describe("`s1`..`s12`, unique within a turn."),
|
||||
.describe("`s1`..`s48`, unique within a turn."),
|
||||
tool: z.enum([...COACH_TOOL_NAMES, "show_result", "snapshot"]),
|
||||
labelKey: z
|
||||
.string()
|
||||
@@ -126,6 +135,18 @@ const coachChartSpecSchema = z.discriminatedUnion("kind", [
|
||||
x: z.string(),
|
||||
series: z.array(z.string()),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal("compare"),
|
||||
mode: z.enum(["periods", "metrics"]),
|
||||
x: z.string(),
|
||||
a: z.string(),
|
||||
b: z.string(),
|
||||
axes: z.union([z.literal(1), z.literal(2)]),
|
||||
})
|
||||
.describe(
|
||||
"v1.41 — two series on one chart: two periods overlaid (`periods`, one axis) or two metrics (`metrics`, one or two axes). `a` and `b` name the value columns, `x` the shared column.",
|
||||
),
|
||||
z.object({
|
||||
kind: z.literal("bar"),
|
||||
x: z.string(),
|
||||
@@ -151,13 +172,13 @@ const coachResultSourceSchema = z.object({
|
||||
});
|
||||
|
||||
const coachResultMetaShape = {
|
||||
ref: resultRefSchema.describe("`r1`..`r6`, unique within a message."),
|
||||
ref: resultRefSchema.describe("`r1`..`r8`, unique within a message."),
|
||||
source: coachResultSourceSchema,
|
||||
shape: z.enum(["timeSeries", "categoryCounts", "distribution", "single"]),
|
||||
titleKey: z.string(),
|
||||
title: z.string(),
|
||||
rowCount: z.number().int().describe("The full row count, before any trim."),
|
||||
chartKind: z.enum(["line", "bar", "histogram"]).nullable(),
|
||||
chartKind: z.enum(["line", "compare", "bar", "histogram"]).nullable(),
|
||||
displayed: z
|
||||
.boolean()
|
||||
.describe(
|
||||
@@ -245,6 +266,50 @@ export const coachMethodSchema = z
|
||||
"How an answer was reached: which sources, windows, counts and aggregation. Never a health value.",
|
||||
});
|
||||
|
||||
// ── Choices and assumptions ───────────────────────────────────────────────
|
||||
|
||||
/** An id the server minted for a plan, a fact or a record event. */
|
||||
const choiceIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
|
||||
|
||||
const coachChoiceValueSchema = z
|
||||
.object({
|
||||
metric: coachScopeSourceSchema.optional(),
|
||||
window: coachWindowEnum.optional(),
|
||||
comparison: z
|
||||
.enum(["previous_period", "year_ago", "baseline_90d"])
|
||||
.optional()
|
||||
.describe("v1.41 — what a comparison is drawn against."),
|
||||
goal: choiceIdSchema
|
||||
.optional()
|
||||
.describe("v1.41 — an active plan or a goal fact, by id."),
|
||||
anchor: choiceIdSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.41 — a medication start, an illness episode or a cycle phase, by id.",
|
||||
),
|
||||
})
|
||||
.describe("What a choice stands for: catalog tokens and ids, never text.");
|
||||
|
||||
const coachAssumptionKindSchema = z.enum(["metric", "window", "comparison"]);
|
||||
|
||||
const coachAssumptionOptionSchema = z.object({
|
||||
labelKey: z.string(),
|
||||
label: z.string(),
|
||||
value: coachChoiceValueSchema,
|
||||
});
|
||||
|
||||
export const coachAssumptionSchema = z
|
||||
.object({
|
||||
kind: coachAssumptionKindSchema,
|
||||
value: coachAssumptionOptionSchema,
|
||||
alternatives: z.array(coachAssumptionOptionSchema).describe("At most 3."),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachAssumption",
|
||||
description:
|
||||
"v1.41 — what an answer assumed instead of asking, from a catalog. Each alternative is offered as a `change_assumption` follow-up.",
|
||||
});
|
||||
|
||||
// ── Follow-ups ────────────────────────────────────────────────────────────
|
||||
|
||||
export const coachFollowUpSchema = z
|
||||
@@ -261,6 +326,7 @@ export const coachFollowUpSchema = z
|
||||
"as_table",
|
||||
"related_metric",
|
||||
"continue",
|
||||
"change_assumption",
|
||||
]),
|
||||
labelKey: z.string(),
|
||||
label: z
|
||||
@@ -269,7 +335,7 @@ export const coachFollowUpSchema = z
|
||||
anchor: z
|
||||
.object({
|
||||
// Empty for a related metric read without a table of its own.
|
||||
ref: z.string().regex(/^(?:r[1-6])?$/),
|
||||
ref: z.string().regex(/^(?:r[1-8])?$/),
|
||||
domain: coachStepDomainSchema,
|
||||
window: coachWindowEnum.optional(),
|
||||
granularity: coachResultGranularitySchema.optional(),
|
||||
@@ -282,6 +348,15 @@ export const coachFollowUpSchema = z
|
||||
"True when the chip is answered from a stored table without a model call.",
|
||||
),
|
||||
origin: z.enum(["server", "model"]),
|
||||
assumption: z
|
||||
.object({
|
||||
kind: coachAssumptionKindSchema,
|
||||
value: coachChoiceValueSchema,
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.41 — on a `change_assumption` chip: the assumption it replaces and the alternative it picks.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachFollowUp",
|
||||
@@ -293,21 +368,27 @@ export const coachFollowUpSchema = z
|
||||
|
||||
export const coachClarificationSchema = z
|
||||
.object({
|
||||
kind: z.enum(["metric", "window", "context"]),
|
||||
kind: z
|
||||
.enum(["metric", "window", "comparison", "goal", "anchor", "context"])
|
||||
.describe("`comparison`, `goal` and `anchor` since v1.41."),
|
||||
choices: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().regex(/^c[1-4]$/),
|
||||
labelKey: z.string(),
|
||||
label: z.string(),
|
||||
value: z.object({
|
||||
metric: coachScopeSourceSchema.optional(),
|
||||
window: coachWindowEnum.optional(),
|
||||
}),
|
||||
value: coachChoiceValueSchema,
|
||||
}),
|
||||
)
|
||||
.describe("At most 4. Metric choices are always ones the record holds."),
|
||||
freeText: z.boolean().describe("Whether a typed answer is welcome."),
|
||||
assumption: z
|
||||
.string()
|
||||
.regex(/^c[1-4]$/)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.41 — the choice that applies when the person does not answer. The question names it, and it is the first choice.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachClarification",
|
||||
@@ -315,6 +396,178 @@ export const coachClarificationSchema = z
|
||||
"The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`.",
|
||||
});
|
||||
|
||||
// ── Activity, memory and plans (v1.41) ────────────────────────────────────
|
||||
|
||||
const coachStopReasonSchema = z.enum(["budget", "time", "cap", "no_progress"]);
|
||||
|
||||
export const coachStopSchema = z
|
||||
.object({
|
||||
reason: coachStopReasonSchema,
|
||||
rounds: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("The rounds the turn ran, the final one included."),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachStop",
|
||||
description:
|
||||
"v1.41 — why the Coach answered before it was done reading: the token budget, the time budget, the round cap, or no new data.",
|
||||
});
|
||||
|
||||
const coachActivityMetaShape = {
|
||||
id: z
|
||||
.string()
|
||||
.regex(/^a[1-9]\d?$/)
|
||||
.describe("`a1`..`a99`, unique within a turn."),
|
||||
phase: z.enum([
|
||||
"thinking",
|
||||
"memory",
|
||||
"fetch",
|
||||
"digest",
|
||||
"checkpoint",
|
||||
"remember",
|
||||
"plan",
|
||||
"asking",
|
||||
"stop",
|
||||
"answer",
|
||||
]),
|
||||
status: z.enum(["running", "done", "empty", "failed"]),
|
||||
round: z.number().int().describe("The tool round, from 1."),
|
||||
labelKey: z
|
||||
.string()
|
||||
.describe("Closed catalog key (`insights.coach.activity.*`)."),
|
||||
label: z
|
||||
.string()
|
||||
.describe(
|
||||
"Rendered on the server in the request locale from the catalog; never model text.",
|
||||
),
|
||||
stepRef: z
|
||||
.string()
|
||||
.regex(/^s[1-9]\d{0,2}$/)
|
||||
.optional()
|
||||
.describe("The `step` a `fetch` entry stands for."),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe("Readings, lookups or entries the server counted."),
|
||||
durationMs: z.number().int().optional(),
|
||||
stop: coachStopReasonSchema.optional().describe("Set on the `stop` entry."),
|
||||
};
|
||||
|
||||
export const coachActivityMetaSchema = z.object(coachActivityMetaShape).meta({
|
||||
id: "CoachActivityMeta",
|
||||
description:
|
||||
"v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog label, counts. The model-written title and text are behind `GET /api/insights/chat/{id}/messages/{messageId}/trail`.",
|
||||
});
|
||||
|
||||
export const coachActivitySchema = z
|
||||
.object({
|
||||
...coachActivityMetaShape,
|
||||
title: z
|
||||
.string()
|
||||
.max(ACTIVITY_TITLE_MAX_CHARS)
|
||||
.optional()
|
||||
.describe("A screened reasoning title or checkpoint sentence."),
|
||||
text: z
|
||||
.string()
|
||||
.max(ACTIVITY_TEXT_MAX_CHARS)
|
||||
.optional()
|
||||
.describe("A screened reasoning summary for the round."),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachActivity",
|
||||
description:
|
||||
"v1.41 — one entry of the live trail, sent as `activity` frames that upsert by `id`. `title` and `text` are model text, screened, and sent only to the account that owns the conversation.",
|
||||
});
|
||||
|
||||
const coachFactCategorySchema = z.enum(COACH_MEMORY_CATEGORIES);
|
||||
|
||||
const coachMemoryNoteMetaShape = {
|
||||
proposal: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes `factId`.",
|
||||
),
|
||||
proposalId: choiceIdSchema.optional(),
|
||||
factId: choiceIdSchema.optional(),
|
||||
category: coachFactCategorySchema,
|
||||
};
|
||||
|
||||
export const coachMemoryNoteMetaSchema = z
|
||||
.object(coachMemoryNoteMetaShape)
|
||||
.meta({
|
||||
id: "CoachMemoryNoteMeta",
|
||||
description:
|
||||
"v1.41 — the fact a reply saved or proposes, as persisted on `metricSource.memoryNote`. No fact text.",
|
||||
});
|
||||
|
||||
export const coachMemoryNoteSchema = z
|
||||
.object({
|
||||
...coachMemoryNoteMetaShape,
|
||||
fact: z.string().max(REMEMBER_FACT_MAX_CHARS),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachMemoryNote",
|
||||
description:
|
||||
"v1.41 — a fact the Coach saved during the turn, or proposes to save (health facts are never saved without a tap). Owner only.",
|
||||
});
|
||||
|
||||
const coachPlanProposalMetaShape = {
|
||||
planId: choiceIdSchema,
|
||||
metric: z.string().max(64),
|
||||
reviewInDays: z
|
||||
.number()
|
||||
.int()
|
||||
.min(PLAN_REVIEW_DAYS.min)
|
||||
.max(PLAN_REVIEW_DAYS.max),
|
||||
};
|
||||
|
||||
export const coachPlanProposalMetaSchema = z
|
||||
.object(coachPlanProposalMetaShape)
|
||||
.meta({
|
||||
id: "CoachPlanProposalMeta",
|
||||
description:
|
||||
"v1.41 — the plan a reply proposes, as persisted on `metricSource.planProposal`. No plan text.",
|
||||
});
|
||||
|
||||
export const coachPlanProposalSchema = z
|
||||
.object({
|
||||
...coachPlanProposalMetaShape,
|
||||
ifCue: z.string(),
|
||||
thenAction: z.string(),
|
||||
target: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachPlanProposal",
|
||||
description:
|
||||
"v1.41 — a plan the Coach proposes, written as `proposed`. Answer with `planDecision: { messageId, planId, accept }`. Owner only.",
|
||||
});
|
||||
|
||||
export const coachTrailSchema = z
|
||||
.object({
|
||||
entries: z.array(
|
||||
z.object({
|
||||
id: z.string().regex(/^a[1-9]\d?$/),
|
||||
title: z.string().max(ACTIVITY_TITLE_MAX_CHARS).optional(),
|
||||
text: z.string().max(ACTIVITY_TEXT_MAX_CHARS).optional(),
|
||||
}),
|
||||
),
|
||||
recalled: z.array(z.string().max(REMEMBER_FACT_MAX_CHARS)).optional(),
|
||||
proposal: z
|
||||
.object({
|
||||
proposalId: choiceIdSchema,
|
||||
category: coachFactCategorySchema,
|
||||
fact: z.string().max(REMEMBER_FACT_MAX_CHARS),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachTrail",
|
||||
description:
|
||||
"v1.41 — the model-written text of a turn's trail and the fact texts it touched. Encrypted at rest and served only to the account that owns the conversation.",
|
||||
});
|
||||
|
||||
// ── The cards and the usage envelope ──────────────────────────────────────
|
||||
|
||||
const coachSuggestionSchema = z
|
||||
@@ -457,6 +710,27 @@ export const coachProvenanceSchema = z
|
||||
.describe(
|
||||
"v1.39.4 — this reply continues an answer that was forced at the round cap (the `continue` chip): the id of that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise.",
|
||||
),
|
||||
activity: z
|
||||
.array(coachActivityMetaSchema)
|
||||
.optional()
|
||||
.describe("v1.41 — the turn's trail, metadata only."),
|
||||
stop: coachStopSchema
|
||||
.optional()
|
||||
.describe("v1.41 — why the answer was forced, when it was."),
|
||||
assumptions: z
|
||||
.array(coachAssumptionSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.41 — what the answer assumed instead of asking, at most two.",
|
||||
),
|
||||
memoryNote: coachMemoryNoteMetaSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.41 — the fact this reply saved or proposes, without its text.",
|
||||
),
|
||||
planProposal: coachPlanProposalMetaSchema
|
||||
.optional()
|
||||
.describe("v1.41 — the plan this reply proposes, without its text."),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachProvenance",
|
||||
@@ -487,6 +761,13 @@ export const coachStreamEventSchema = z
|
||||
conversationId: z.string(),
|
||||
messageId: z.string(),
|
||||
usage: coachUsageSchema.optional(),
|
||||
stop: coachStopSchema.optional(),
|
||||
withheldResults: z
|
||||
.literal(true)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.41 — the turn was blocked after interim tables went out; remove them.",
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("error"),
|
||||
@@ -495,7 +776,14 @@ export const coachStreamEventSchema = z
|
||||
reason: z.enum(AI_UNAVAILABLE_REASONS).optional(),
|
||||
}),
|
||||
z.object({ type: z.literal("step"), step: coachStepSchema }),
|
||||
z.object({ type: z.literal("result"), result: coachResultTableSchema }),
|
||||
z.object({
|
||||
type: z.literal("result"),
|
||||
result: coachResultTableSchema,
|
||||
interim: z
|
||||
.literal(true)
|
||||
.optional()
|
||||
.describe("v1.41 — sent while the turn still runs."),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("followUps"),
|
||||
followUps: z.array(coachFollowUpSchema),
|
||||
@@ -504,9 +792,15 @@ export const coachStreamEventSchema = z
|
||||
type: z.literal("clarification"),
|
||||
clarification: coachClarificationSchema,
|
||||
}),
|
||||
z.object({ type: z.literal("activity"), activity: coachActivitySchema }),
|
||||
z.object({ type: z.literal("memoryNote"), note: coachMemoryNoteSchema }),
|
||||
z.object({
|
||||
type: z.literal("planProposal"),
|
||||
proposal: coachPlanProposalSchema,
|
||||
}),
|
||||
])
|
||||
.meta({
|
||||
id: "CoachStreamEvent",
|
||||
description:
|
||||
"One Server-Sent Events frame of a Coach turn, sent as `data: <json>\\n\\n` and dispatched on `type`. Order: `step*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → `clarification?` → `followUps?` → `done`, or a single `error`. Clients ignore a `type` they do not know.",
|
||||
"One Server-Sent Events frame of a Coach turn, sent as `data: <json>\\n\\n` and dispatched on `type`. Order: `(activity | step)*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → `memoryNote?` → `planProposal?` → `clarification?` → `followUps?` → `done`, or a single `error`. `result` frames with `interim: true` may also arrive among the `activity` frames. Clients ignore a `type` they do not know.",
|
||||
});
|
||||
+274
-7
@@ -183,6 +183,32 @@ export const coachChatRequestSchema = z.object({
|
||||
choiceId: z.string().min(1).max(8).optional(),
|
||||
})
|
||||
.optional(),
|
||||
/**
|
||||
* v1.41 — the person answered a fact proposal ("Yes, remember it" / "No").
|
||||
* `messageId` is the assistant message that offered it, `proposalId` the
|
||||
* proposal on that message. The client never sends the fact: the server
|
||||
* reads it from what it stored on that message, so a tap can only accept
|
||||
* what the Coach actually proposed.
|
||||
*/
|
||||
memoryDecision: z
|
||||
.object({
|
||||
messageId: z.string().min(1).max(64),
|
||||
proposalId: z.string().min(1).max(64),
|
||||
accept: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
/**
|
||||
* v1.41 — the person answered a plan proposal ("Take on this plan" /
|
||||
* "Not now"). `planId` is the proposed plan the assistant message carried;
|
||||
* the server holds it to that message and to the person's own plans.
|
||||
*/
|
||||
planDecision: z
|
||||
.object({
|
||||
messageId: z.string().min(1).max(64),
|
||||
planId: z.string().min(1).max(64),
|
||||
accept: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -247,6 +273,18 @@ export interface CoachUsage {
|
||||
* step* → token* → provenance → result* → suggestion? →
|
||||
* suggestedAction? → clarification? → followUps? → done
|
||||
*
|
||||
* v1.41 — three more additive frames: `activity` (the live trail: one
|
||||
* entry per phase of the turn, upserted by id), `memoryNote` (a fact the
|
||||
* Coach saved or proposes to save) and `planProposal` (a plan it proposes).
|
||||
* `result` may carry `interim: true` while the turn still runs, and `done`
|
||||
* carries `stop` when the answer was forced and `withheldResults` when the
|
||||
* interim tables must be taken down again. The `reasoning` frame stays
|
||||
* accepted and is never sent. Frame order:
|
||||
*
|
||||
* (activity | step)* → token* → provenance → result* → suggestion? →
|
||||
* suggestedAction? → memoryNote? → planProposal? → clarification? →
|
||||
* followUps? → done
|
||||
*
|
||||
* No new frame carries a top-level key the older native client decodes
|
||||
* (`token`, `conversationId`, `messageId`, `code`, `message`, `suggestion`,
|
||||
* `metricSource`, `usage`), so an older client drops them whole.
|
||||
@@ -268,6 +306,13 @@ export type CoachStreamEvent =
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
usage?: CoachUsage;
|
||||
/** v1.41 — why the answer was forced, when it was. */
|
||||
stop?: CoachStop;
|
||||
/**
|
||||
* v1.41 — the turn was blocked after interim tables went out; the
|
||||
* client removes them.
|
||||
*/
|
||||
withheldResults?: true;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
@@ -281,9 +326,155 @@ export type CoachStreamEvent =
|
||||
reason?: AiUnavailableReason;
|
||||
}
|
||||
| { type: "step"; step: CoachStep }
|
||||
| { type: "result"; result: CoachResultTable }
|
||||
| {
|
||||
type: "result";
|
||||
result: CoachResultTable;
|
||||
/**
|
||||
* v1.41 — sent while the turn still runs, right after the call that
|
||||
* read it. The answer decides later whether it is shown or sits under
|
||||
* "Data used".
|
||||
*/
|
||||
interim?: true;
|
||||
}
|
||||
| { type: "followUps"; followUps: CoachFollowUp[] }
|
||||
| { type: "clarification"; clarification: CoachClarification };
|
||||
| { type: "clarification"; clarification: CoachClarification }
|
||||
| { type: "activity"; activity: CoachActivity }
|
||||
| { type: "memoryNote"; note: CoachMemoryNote }
|
||||
| { type: "planProposal"; proposal: CoachPlanProposal };
|
||||
|
||||
// ── Activity (v1.41) ────────────────────────────────────────────────────
|
||||
// The live trail of a turn: what the Coach is doing right now, one entry per
|
||||
// phase. The metadata is plaintext and persisted on `metricSource.activity`;
|
||||
// the model-written `title` and `text` reach only the owner and are stored
|
||||
// encrypted in `coach_messages.trail_encrypted`.
|
||||
|
||||
export type CoachActivityPhase =
|
||||
| "thinking"
|
||||
| "memory"
|
||||
| "fetch"
|
||||
| "digest"
|
||||
| "checkpoint"
|
||||
| "remember"
|
||||
| "plan"
|
||||
| "asking"
|
||||
| "stop"
|
||||
| "answer";
|
||||
|
||||
/** Why the loop forced its final answer. */
|
||||
export type CoachStopReason = "budget" | "time" | "cap" | "no_progress";
|
||||
|
||||
export interface CoachStop {
|
||||
reason: CoachStopReason;
|
||||
/** The rounds the turn ran, the final one included. */
|
||||
rounds: number;
|
||||
}
|
||||
|
||||
/** Plaintext; rides `metricSource.activity`. */
|
||||
export interface CoachActivityMeta {
|
||||
/** `a1`..`a99`, unique within a turn; frames upsert by id. */
|
||||
id: string;
|
||||
phase: CoachActivityPhase;
|
||||
status: CoachStepStatus;
|
||||
/** The tool round the entry belongs to, from 1. */
|
||||
round: number;
|
||||
/** Closed catalog key (`insights.coach.activity.*`). */
|
||||
labelKey: string;
|
||||
/**
|
||||
* The label rendered on the server in the request locale. Always catalog
|
||||
* text with server-chosen values, never model text.
|
||||
*/
|
||||
label: string;
|
||||
/** The `step` a `fetch` entry stands for (`s1`..). */
|
||||
stepRef?: string;
|
||||
/** Readings, lookups or remembered entries the server counted. */
|
||||
count?: number;
|
||||
durationMs?: number;
|
||||
/** Set on the `stop` entry. */
|
||||
stop?: CoachStopReason;
|
||||
}
|
||||
|
||||
/** One trail entry on the wire: the metadata plus the owner-only model text. */
|
||||
export interface CoachActivity extends CoachActivityMeta {
|
||||
/** A screened reasoning title or checkpoint sentence, at most 80 chars. */
|
||||
title?: string;
|
||||
/** A screened reasoning summary for the round, at most 400 chars. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// ── Memory and plans (v1.41) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The categories a Coach fact is filed under. The extraction's list in
|
||||
* `facts.ts` plus `medication`, which only the in-turn `remember_fact` tool
|
||||
* and the remember button write. `condition`, `constraint` and `medication`
|
||||
* are health facts: never saved without the person's tap.
|
||||
*/
|
||||
export const COACH_MEMORY_CATEGORIES = [
|
||||
"preference",
|
||||
"goal",
|
||||
"context",
|
||||
"condition",
|
||||
"constraint",
|
||||
"medication",
|
||||
] as const;
|
||||
|
||||
export type CoachMemoryCategory = (typeof COACH_MEMORY_CATEGORIES)[number];
|
||||
|
||||
/** Plaintext; rides `metricSource.memoryNote`. No fact text. */
|
||||
export interface CoachMemoryNoteMeta {
|
||||
/**
|
||||
* `true`: a proposal waiting for the person ("Yes, remember it" / "No"),
|
||||
* answered with `memoryDecision`. `false`: already saved, undone by
|
||||
* deleting `factId`.
|
||||
*/
|
||||
proposal: boolean;
|
||||
/** The proposal on this message (`proposal: true`). */
|
||||
proposalId?: string;
|
||||
/** The saved fact (`proposal: false`). */
|
||||
factId?: string;
|
||||
category: CoachMemoryCategory;
|
||||
}
|
||||
|
||||
/** On the wire, owner only: the metadata plus the fact itself. */
|
||||
export interface CoachMemoryNote extends CoachMemoryNoteMeta {
|
||||
/** The fact as it will be stored, at most 160 chars. */
|
||||
fact: string;
|
||||
}
|
||||
|
||||
/** Plaintext; rides `metricSource.planProposal`. No plan text. */
|
||||
export interface CoachPlanProposalMeta {
|
||||
/** The `CoachPlan` row, written as `proposed`. */
|
||||
planId: string;
|
||||
/** The metric the plan moves, e.g. `WEIGHT`. */
|
||||
metric: string;
|
||||
/** 7..56. */
|
||||
reviewInDays: number;
|
||||
}
|
||||
|
||||
/** On the wire, owner only: the metadata plus the plan's own words. */
|
||||
export interface CoachPlanProposal extends CoachPlanProposalMeta {
|
||||
ifCue: string;
|
||||
thenAction: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — the decrypted `coach_messages.trail_encrypted` of one message, as
|
||||
* `GET …/messages/{messageId}/trail` serves it to the owner. Model text and
|
||||
* fact text only; the structure lives in `metricSource`.
|
||||
*/
|
||||
export interface CoachTrail {
|
||||
/** Title and text per activity entry, by `CoachActivityMeta.id`. */
|
||||
entries: Array<{ id: string; title?: string; text?: string }>;
|
||||
/** The facts recalled into the turn (the `memory` entry). */
|
||||
recalled?: string[];
|
||||
/** The fact proposal's text, which a `memoryDecision` reads back. */
|
||||
proposal?: {
|
||||
proposalId: string;
|
||||
category: CoachMemoryCategory;
|
||||
fact: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Steps (v1.39.4) ─────────────────────────────────────────────────────
|
||||
// What the Coach is reading while a turn runs. Labels come from a closed
|
||||
@@ -307,7 +498,7 @@ export type CoachStepReason =
|
||||
| "invalid_arguments";
|
||||
|
||||
export interface CoachStep {
|
||||
/** `s1`..`s12`, unique within a turn; frames upsert by id. */
|
||||
/** `s1`..`s48`, unique within a turn; frames upsert by id. */
|
||||
id: string;
|
||||
tool: CoachToolName | "show_result" | "snapshot";
|
||||
/** Closed catalog key, e.g. `coach.step.read`. */
|
||||
@@ -351,6 +542,19 @@ export type CoachResultCell = string | number | null;
|
||||
|
||||
export type CoachChartSpec =
|
||||
| { kind: "line"; x: string; series: string[] }
|
||||
| {
|
||||
/**
|
||||
* v1.41 — two series on one chart: two periods overlaid (`periods`,
|
||||
* one axis) or two metrics side by side (`metrics`, one or two axes).
|
||||
* `a` and `b` name the value columns, `x` the shared column.
|
||||
*/
|
||||
kind: "compare";
|
||||
mode: "periods" | "metrics";
|
||||
x: string;
|
||||
a: string;
|
||||
b: string;
|
||||
axes: 1 | 2;
|
||||
}
|
||||
| {
|
||||
kind: "bar";
|
||||
x: string;
|
||||
@@ -374,7 +578,7 @@ export interface CoachResultSource {
|
||||
|
||||
/** Plaintext; rides `metricSource.results`. */
|
||||
export interface CoachResultMeta {
|
||||
/** `r1`..`r6`, per message. */
|
||||
/** `r1`..`r8`, per message. */
|
||||
ref: string;
|
||||
source: CoachResultSource;
|
||||
shape: CoachResultShape;
|
||||
@@ -445,7 +649,8 @@ export type CoachFollowUpKind =
|
||||
| "as_chart"
|
||||
| "as_table"
|
||||
| "related_metric"
|
||||
| "continue";
|
||||
| "continue"
|
||||
| "change_assumption";
|
||||
|
||||
export interface CoachFollowUp {
|
||||
/** `f1`..`f3`. */
|
||||
@@ -463,25 +668,77 @@ export interface CoachFollowUp {
|
||||
/** True when the chip is answered from a stored table, without a model. */
|
||||
reuse: boolean;
|
||||
origin: "server" | "model";
|
||||
/**
|
||||
* v1.41 — on a `change_assumption` chip: the assumption it replaces and
|
||||
* the alternative it picks.
|
||||
*/
|
||||
assumption?: { kind: CoachAssumptionKind; value: CoachChoiceValue };
|
||||
}
|
||||
|
||||
// ── Clarification (v1.39.4) ─────────────────────────────────────────────
|
||||
// A question with choices. The question text is the assistant message
|
||||
// itself, encrypted like any reply; only the choices ride here.
|
||||
|
||||
/** v1.41 — what a comparison is drawn against. */
|
||||
export type CoachComparisonBasis =
|
||||
"previous_period" | "year_ago" | "baseline_90d";
|
||||
|
||||
/**
|
||||
* What a choice stands for. One field per kind; `goal` and `anchor` are ids
|
||||
* of the person's own plans, facts or record events, never text.
|
||||
*/
|
||||
export interface CoachChoiceValue {
|
||||
metric?: CoachScopeSource;
|
||||
window?: CoachScopeWindow;
|
||||
/** v1.41 */
|
||||
comparison?: CoachComparisonBasis;
|
||||
/** v1.41 — an active plan or a goal fact. */
|
||||
goal?: string;
|
||||
/** v1.41 — a medication start, an illness episode, a cycle phase. */
|
||||
anchor?: string;
|
||||
}
|
||||
|
||||
export interface CoachClarificationChoice {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
value: { metric?: CoachScopeSource; window?: CoachScopeWindow };
|
||||
value: CoachChoiceValue;
|
||||
}
|
||||
|
||||
export type CoachClarificationKind =
|
||||
"metric" | "window" | "comparison" | "goal" | "anchor" | "context";
|
||||
|
||||
export interface CoachClarification {
|
||||
kind: "metric" | "window" | "context";
|
||||
/** `comparison`, `goal` and `anchor` since v1.41. */
|
||||
kind: CoachClarificationKind;
|
||||
/** At most 4; metric choices are always ones the record holds. */
|
||||
choices: CoachClarificationChoice[];
|
||||
/** Whether a typed answer is welcome beside the choices. */
|
||||
freeText: boolean;
|
||||
/**
|
||||
* v1.41 — the choice that applies when the person does not answer (`c1`..);
|
||||
* the question names it, and it is the first choice.
|
||||
*/
|
||||
assumption?: string;
|
||||
}
|
||||
|
||||
// ── Assumptions (v1.41) ─────────────────────────────────────────────────
|
||||
// What an answer assumed instead of asking. Catalog values only, at most two
|
||||
// per answer; each offers its alternatives as `change_assumption` chips.
|
||||
|
||||
export type CoachAssumptionKind = "metric" | "window" | "comparison";
|
||||
|
||||
export interface CoachAssumptionOption {
|
||||
labelKey: string;
|
||||
label: string;
|
||||
value: CoachChoiceValue;
|
||||
}
|
||||
|
||||
export interface CoachAssumption {
|
||||
kind: CoachAssumptionKind;
|
||||
value: CoachAssumptionOption;
|
||||
/** At most 3. */
|
||||
alternatives: CoachAssumptionOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -672,6 +929,16 @@ export interface CoachProvenance {
|
||||
* further "keep looking" chip, so an answer is continued at most once.
|
||||
*/
|
||||
continuationOf?: string;
|
||||
/** v1.41 — the turn's trail, metadata only; the text is behind `…/trail`. */
|
||||
activity?: CoachActivityMeta[];
|
||||
/** v1.41 — why the answer was forced, when it was. */
|
||||
stop?: CoachStop;
|
||||
/** v1.41 — what the answer assumed instead of asking, at most two. */
|
||||
assumptions?: CoachAssumption[];
|
||||
/** v1.41 — the fact this reply saved or proposes, without its text. */
|
||||
memoryNote?: CoachMemoryNoteMeta;
|
||||
/** v1.41 — the plan this reply proposes, without its text. */
|
||||
planProposal?: CoachPlanProposalMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKGROUND_REASONING_JOBS,
|
||||
BACKGROUND_REASONING_LEVEL,
|
||||
DEFAULT_REASONING_LEVEL,
|
||||
DEFAULT_REASONING_MAX_EFFORT,
|
||||
REASONING_LEVELS,
|
||||
REASONING_MAX_EFFORTS,
|
||||
REASONING_THINKING_BUDGET,
|
||||
capReasoningLevel,
|
||||
isReasoningLevel,
|
||||
} from "../levels";
|
||||
|
||||
describe("reasoning levels", () => {
|
||||
it("defaults to medium for a person and to no cap for an instance", () => {
|
||||
expect(DEFAULT_REASONING_LEVEL).toBe("medium");
|
||||
expect(DEFAULT_REASONING_MAX_EFFORT).toBe("high");
|
||||
expect(REASONING_MAX_EFFORTS).toEqual(
|
||||
REASONING_LEVELS.filter((level) => level !== "off"),
|
||||
);
|
||||
});
|
||||
|
||||
it("recognises exactly the four levels", () => {
|
||||
for (const level of REASONING_LEVELS) {
|
||||
expect(isReasoningLevel(level)).toBe(true);
|
||||
}
|
||||
for (const other of ["none", "minimal", "xhigh", "", null, 2]) {
|
||||
expect(isReasoningLevel(other)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("caps a level at the operator's maximum and never raises one", () => {
|
||||
expect(capReasoningLevel("high", "medium")).toBe("medium");
|
||||
expect(capReasoningLevel("high", "low")).toBe("low");
|
||||
expect(capReasoningLevel("medium", "high")).toBe("medium");
|
||||
expect(capReasoningLevel("low", "medium")).toBe("low");
|
||||
expect(capReasoningLevel("off", "low")).toBe("off");
|
||||
});
|
||||
|
||||
it("gives every level above off a growing thinking budget of at least 1024", () => {
|
||||
const budgets = REASONING_LEVELS.filter((level) => level !== "off").map(
|
||||
(level) => REASONING_THINKING_BUDGET[level as "low"],
|
||||
);
|
||||
expect(budgets[0]).toBeGreaterThanOrEqual(1_024);
|
||||
expect([...budgets].sort((a, b) => a - b)).toEqual(budgets);
|
||||
});
|
||||
|
||||
it("has a level for every background job, operator-paid never above its own", () => {
|
||||
expect(Object.keys(BACKGROUND_REASONING_LEVEL).sort()).toEqual(
|
||||
[...BACKGROUND_REASONING_JOBS].sort(),
|
||||
);
|
||||
for (const job of BACKGROUND_REASONING_JOBS) {
|
||||
const { level, operatorLevel } = BACKGROUND_REASONING_LEVEL[job];
|
||||
expect(level).not.toBe("off");
|
||||
expect(REASONING_LEVELS.indexOf(operatorLevel)).toBeLessThanOrEqual(
|
||||
REASONING_LEVELS.indexOf(level),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.41 — the reasoning levels, the thinking budget behind each, and the
|
||||
* background jobs that may reason.
|
||||
*
|
||||
* One vocabulary for every layer: the person's Coach preference
|
||||
* (`coachPrefsJson.reasoning`), the operator's cap
|
||||
* (`app_settings.ai_reasoning_max_effort`), the resolved value a call carries
|
||||
* (`CompletionParams.reasoning.effort`) and the level `/api/auth/me` publishes.
|
||||
* Each provider client maps it onto its own wire; nothing outside a client
|
||||
* speaks a provider's dialect.
|
||||
*
|
||||
* A leaf on purpose, like `../reasoning-effort.ts`: `types.ts` imports it, and
|
||||
* `types.ts` is reachable from workers that must not reach the provider
|
||||
* machinery. No import from `provider-chain.ts`, and none from a client.
|
||||
*
|
||||
* Client-safe: no server import.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `off` is the person's choice, not a promise about the wire: where a provider
|
||||
* cannot switch reasoning off, the client sends its lowest setting instead and
|
||||
* the settings say "Minimal" rather than "Off" (`offIsReal` in
|
||||
* `support.ts`).
|
||||
*/
|
||||
export const REASONING_LEVELS = ["off", "low", "medium", "high"] as const;
|
||||
|
||||
export type ReasoningLevel = (typeof REASONING_LEVELS)[number];
|
||||
|
||||
/** The level an account that never chose one gets. */
|
||||
export const DEFAULT_REASONING_LEVEL: ReasoningLevel = "medium";
|
||||
|
||||
/** The values the operator's cap may take; switching off is the other field. */
|
||||
export const REASONING_MAX_EFFORTS = ["low", "medium", "high"] as const;
|
||||
|
||||
export type ReasoningMaxEffort = (typeof REASONING_MAX_EFFORTS)[number];
|
||||
|
||||
/** The cap an instance that never set one has: no cap. */
|
||||
export const DEFAULT_REASONING_MAX_EFFORT: ReasoningMaxEffort = "high";
|
||||
|
||||
export function isReasoningLevel(value: unknown): value is ReasoningLevel {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(REASONING_LEVELS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** `level`, lowered to `max` when it is above it. */
|
||||
export function capReasoningLevel(
|
||||
level: ReasoningLevel,
|
||||
max: ReasoningMaxEffort,
|
||||
): ReasoningLevel {
|
||||
return REASONING_LEVELS.indexOf(level) > REASONING_LEVELS.indexOf(max)
|
||||
? max
|
||||
: level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking tokens per level, for the providers that take a token budget
|
||||
* rather than a named effort (Anthropic's manual mode, gateways that accept
|
||||
* `max_tokens`). A round's output allowance is its answer budget plus this.
|
||||
*/
|
||||
export const REASONING_THINKING_BUDGET: Readonly<
|
||||
Record<Exclude<ReasoningLevel, "off">, number>
|
||||
> = {
|
||||
low: 1_024,
|
||||
medium: 4_096,
|
||||
high: 12_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* The background jobs that may reason. Every other unattended surface (status
|
||||
* cards, batch notes, archetypes, workout and reaction lines, nudges, document
|
||||
* reading, OCR) never does, and the inventory test freezes this list.
|
||||
*/
|
||||
export const BACKGROUND_REASONING_JOBS = [
|
||||
"daily_briefing",
|
||||
"period_narrative_month",
|
||||
"period_narrative_week",
|
||||
"memory_summary",
|
||||
"memory_facts",
|
||||
"memory_plans",
|
||||
] as const;
|
||||
|
||||
export type BackgroundReasoningJob = (typeof BACKGROUND_REASONING_JOBS)[number];
|
||||
|
||||
/**
|
||||
* The level each job asks for: `level` on the person's own plan, key or local
|
||||
* model, `operatorLevel` when the operator pays for the call. Still capped by
|
||||
* the operator's settings like every other call.
|
||||
*/
|
||||
export const BACKGROUND_REASONING_LEVEL: Readonly<
|
||||
Record<
|
||||
BackgroundReasoningJob,
|
||||
{ level: ReasoningLevel; operatorLevel: ReasoningLevel }
|
||||
>
|
||||
> = {
|
||||
daily_briefing: { level: "medium", operatorLevel: "low" },
|
||||
period_narrative_month: { level: "medium", operatorLevel: "low" },
|
||||
period_narrative_week: { level: "low", operatorLevel: "low" },
|
||||
memory_summary: { level: "low", operatorLevel: "low" },
|
||||
memory_facts: { level: "low", operatorLevel: "low" },
|
||||
memory_plans: { level: "medium", operatorLevel: "low" },
|
||||
};
|
||||
|
||||
// ── Message keys ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One option label per level. `minimal` stands in for `off` where the active
|
||||
* provider cannot switch reasoning off.
|
||||
*/
|
||||
export const REASONING_LEVEL_LABEL_KEYS = {
|
||||
off: "insights.coach.reasoning.off",
|
||||
minimal: "insights.coach.reasoning.minimal",
|
||||
low: "insights.coach.reasoning.low",
|
||||
medium: "insights.coach.reasoning.medium",
|
||||
high: "insights.coach.reasoning.high",
|
||||
} as const;
|
||||
|
||||
/** The Coach settings field around the options. */
|
||||
export const REASONING_SETTING_KEYS = {
|
||||
label: "insights.coach.reasoning.label",
|
||||
hint: "insights.coach.reasoning.hint",
|
||||
capped: "insights.coach.reasoning.capped",
|
||||
disabled: "insights.coach.reasoning.disabled",
|
||||
} as const;
|
||||
|
||||
/** The operator's two controls in the admin assistant section. */
|
||||
export const REASONING_ADMIN_KEYS = {
|
||||
enabled: "admin.assistant.reasoning.enabled",
|
||||
max: "admin.assistant.reasoning.max",
|
||||
} as const;
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReasoningEffort } from "./reasoning-effort";
|
||||
import type { ReasoningLevel } from "./reasoning/levels";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// ─── Insight Result Schema ─────────────────────────────────
|
||||
@@ -252,6 +253,37 @@ export interface AiMessage {
|
||||
* A no-op on every other provider (their prefix caching is automatic).
|
||||
*/
|
||||
cacheBreakpoint?: boolean;
|
||||
/**
|
||||
* v1.41 — opaque provider state carried from one tool round to the next on
|
||||
* an assistant turn: the reasoning items a provider requires back verbatim
|
||||
* (encrypted reasoning items, thinking blocks with their signatures,
|
||||
* reasoning details). Only the client whose `providerType` and `model` match
|
||||
* re-inserts it; every other client drops it. Lives in memory for one turn:
|
||||
* never persisted, never logged, never passed to `annotate()`.
|
||||
*/
|
||||
providerState?: AiProviderState;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — provider state threaded between the rounds of one turn. `items` is
|
||||
* whatever the producing client needs back, unchanged; nothing outside that
|
||||
* client reads it.
|
||||
*/
|
||||
export interface AiProviderState {
|
||||
providerType: ProviderType;
|
||||
model: string;
|
||||
items: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.41 — one live reasoning event a client reports while a call runs.
|
||||
* `title` is the first bold line of a summary once it has closed, `text` a
|
||||
* finished summary, `done` the end of the reasoning for this call. Model
|
||||
* text: the receiver screens it before anything reaches a person.
|
||||
*/
|
||||
export interface AiReasoningEvent {
|
||||
kind: "title" | "text" | "done";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CompletionParams {
|
||||
@@ -334,6 +366,20 @@ export interface CompletionParams {
|
||||
* `ai-call-timeout-guard.test.ts`.
|
||||
*/
|
||||
timeoutPolicy?: "surface-ceiling";
|
||||
/**
|
||||
* v1.41 — the reasoning this call asks for, already resolved against the
|
||||
* person's level, the operator's cap and what the provider supports
|
||||
* (`src/lib/ai/reasoning/resolve.ts`). `summaries` asks for reasoning
|
||||
* summaries where the provider offers them. Absent → today's behaviour: the
|
||||
* client sends exactly what it sent before the field existed.
|
||||
*/
|
||||
reasoning?: { effort: ReasoningLevel; summaries: boolean };
|
||||
/**
|
||||
* v1.41 — live reasoning events, called as they arrive on clients that read
|
||||
* the provider's stream, or once after the reply on clients that do not.
|
||||
* Never called when `reasoning` is absent.
|
||||
*/
|
||||
onReasoning?: (event: AiReasoningEvent) => void;
|
||||
}
|
||||
|
||||
export interface CompletionResult {
|
||||
@@ -358,6 +404,22 @@ export interface CompletionResult {
|
||||
* `"tool_calls"`. Absent when the provider does not surface it.
|
||||
*/
|
||||
finishReason?: "stop" | "tool_calls" | "length";
|
||||
/**
|
||||
* v1.41 — what the call reasoned, when it reasoned. `summary` holds the
|
||||
* finished summaries (model text, unscreened), `tokens` the reasoning tokens
|
||||
* the provider billed where it reports them, `downgradedTo` the level the
|
||||
* client fell back to after the provider refused the requested one.
|
||||
*/
|
||||
reasoning?: {
|
||||
summary: string[];
|
||||
tokens: number | null;
|
||||
downgradedTo?: ReasoningLevel;
|
||||
};
|
||||
/**
|
||||
* v1.41 — the state the next round of the same turn hands back on its
|
||||
* assistant message (see {@link AiMessage.providerState}).
|
||||
*/
|
||||
providerState?: AiProviderState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -232,6 +232,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
|
||||
// v1.39.4 — the tables of values a turn read, JSON under the same codec.
|
||||
{ model: "CoachMessage", field: "resultsEncrypted", kind: "bytes" },
|
||||
// v1.41 — the model text of the turn's trail, JSON under the same codec.
|
||||
{ model: "CoachMessage", field: "trailEncrypted", kind: "bytes" },
|
||||
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
|
||||
// v1.39.3 — the title: the opening words of the first message, or a rename.
|
||||
{ model: "CoachConversation", field: "titleEncrypted", kind: "bytes" },
|
||||
|
||||
@@ -90,6 +90,16 @@ export interface CoachMessageBackupEntry {
|
||||
* unreadable-row marker stands in for tables that did not decrypt.
|
||||
*/
|
||||
resultsJson?: string | null;
|
||||
/**
|
||||
* v1.41 — the model text of the turn's trail, as ciphertext in base64.
|
||||
* Present on a disaster-recovery payload only; null on a turn without one.
|
||||
*/
|
||||
trailEncrypted?: string | null;
|
||||
/**
|
||||
* v1.41 — the same trail as readable JSON (a `CoachTrail`), on a portable
|
||||
* payload only; null on a turn without one.
|
||||
*/
|
||||
trailJson?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -167,6 +177,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = {
|
||||
tokensUsed: true,
|
||||
model: true,
|
||||
resultsEncrypted: true,
|
||||
trailEncrypted: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
@@ -268,6 +279,14 @@ export async function buildCoachBackupSection(
|
||||
: null,
|
||||
}
|
||||
: { resultsJson: decryptTurnSoft(message.resultsEncrypted) }),
|
||||
// v1.41 — and so does the trail.
|
||||
...(disasterRecovery
|
||||
? {
|
||||
trailEncrypted: message.trailEncrypted
|
||||
? Buffer.from(message.trailEncrypted).toString("base64")
|
||||
: null,
|
||||
}
|
||||
: { trailJson: decryptTurnSoft(message.trailEncrypted) }),
|
||||
createdAt: message.createdAt.toISOString(),
|
||||
})),
|
||||
attachments: row.attachments.map((attachment) => ({
|
||||
@@ -340,6 +359,8 @@ export type RestoredCoachMessage = Pick<
|
||||
| "model"
|
||||
| "resultsEncrypted"
|
||||
| "resultsJson"
|
||||
| "trailEncrypted"
|
||||
| "trailJson"
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -424,6 +445,7 @@ export async function restoreCoachData(
|
||||
tokensUsed: message.tokensUsed ?? null,
|
||||
model: message.model ?? null,
|
||||
resultsEncrypted: resolveResultsBytes(message),
|
||||
trailEncrypted: resolveTrailBytes(message),
|
||||
createdAt: new Date(message.createdAt),
|
||||
})),
|
||||
},
|
||||
@@ -497,6 +519,18 @@ function resolveResultsBytes(
|
||||
return message.resultsJson ? encryptToBytes(message.resultsJson) : null;
|
||||
}
|
||||
|
||||
/** v1.41 — a turn's stored trail, from either end of the contract. */
|
||||
function resolveTrailBytes(
|
||||
message: RestoredCoachMessage,
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
if (message.trailEncrypted !== undefined) {
|
||||
return message.trailEncrypted === null
|
||||
? null
|
||||
: decodeBase64(message.trailEncrypted);
|
||||
}
|
||||
return message.trailJson ? encryptToBytes(message.trailJson) : null;
|
||||
}
|
||||
|
||||
function resolveTitleBytes(
|
||||
conversation: RestoredCoachConversation,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
@@ -571,6 +605,15 @@ export interface CoachFactBackupEntry {
|
||||
category: string;
|
||||
confidence: number;
|
||||
sourceConversationId: string | null;
|
||||
/** v1.41 — `user`, `coach`, `extracted` or `pattern`. */
|
||||
source: string;
|
||||
/** v1.41 */
|
||||
lastUsedAt: string | null;
|
||||
/**
|
||||
* v1.41 — the message the fact came out of. A bare id like
|
||||
* `sourceConversationId`, resolved the same way on restore.
|
||||
*/
|
||||
sourceMessageId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string | null;
|
||||
@@ -670,6 +713,9 @@ export async function buildCoachMemoryBackupSection(
|
||||
category: row.category,
|
||||
confidence: row.confidence,
|
||||
sourceConversationId: row.sourceConversationId,
|
||||
source: row.source,
|
||||
lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
|
||||
sourceMessageId: row.sourceMessageId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
})),
|
||||
@@ -726,6 +772,9 @@ const COACH_FACT_BACKUP_SELECT = {
|
||||
category: true,
|
||||
confidence: true,
|
||||
sourceConversationId: true,
|
||||
source: true,
|
||||
lastUsedAt: true,
|
||||
sourceMessageId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
deletedAt: true,
|
||||
@@ -795,6 +844,9 @@ export type RestoredCoachFact = Pick<
|
||||
| "fact"
|
||||
| "confidence"
|
||||
| "sourceConversationId"
|
||||
| "source"
|
||||
| "lastUsedAt"
|
||||
| "sourceMessageId"
|
||||
| "deletedAt"
|
||||
>
|
||||
>;
|
||||
@@ -859,12 +911,17 @@ export interface CoachMemoryRestoreInput {
|
||||
*
|
||||
* Plans go in before reminders for the same reason one step down: a reminder's
|
||||
* `relatedPlanId` is resolved against the plans this function has just written.
|
||||
*
|
||||
* A fact's `sourceMessageId` (v1.41) is resolved against the messages the
|
||||
* conversation restore wrote, and nulled and reported the same way when it
|
||||
* names one the file did not carry.
|
||||
*/
|
||||
export async function restoreCoachMemoryData(
|
||||
tx: Prisma.TransactionClient,
|
||||
ownerId: string,
|
||||
payload: CoachMemoryRestoreInput,
|
||||
conversationIds: ReadonlySet<string>,
|
||||
messageIds: ReadonlySet<string>,
|
||||
skips: RestoreSkipLog,
|
||||
): Promise<CoachMemoryRestoreCleared> {
|
||||
const [clearedFacts, clearedPlans, clearedReminders] = await Promise.all([
|
||||
@@ -884,6 +941,12 @@ export async function restoreCoachMemoryData(
|
||||
danglingRefs.push(id);
|
||||
return null;
|
||||
};
|
||||
const resolveMessage = (id: string | null | undefined) => {
|
||||
if (!id) return null;
|
||||
if (messageIds.has(id)) return id;
|
||||
danglingRefs.push(id);
|
||||
return null;
|
||||
};
|
||||
|
||||
if (payload.coachFacts.length > 0) {
|
||||
await tx.coachFact.createMany({
|
||||
@@ -894,6 +957,12 @@ export async function restoreCoachMemoryData(
|
||||
category: fact.category,
|
||||
confidence: fact.confidence ?? 50,
|
||||
sourceConversationId: resolveConversation(fact.sourceConversationId),
|
||||
// A file written before v1.41 states no source; every fact then came
|
||||
// from the extraction or the matcher, which is what the column's
|
||||
// default says too.
|
||||
source: fact.source ?? "extracted",
|
||||
lastUsedAt: fact.lastUsedAt ? new Date(fact.lastUsedAt) : null,
|
||||
sourceMessageId: resolveMessage(fact.sourceMessageId),
|
||||
createdAt: new Date(fact.createdAt),
|
||||
updatedAt: new Date(fact.updatedAt),
|
||||
deletedAt: fact.deletedAt ? new Date(fact.deletedAt) : null,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/lib/medications/course-window";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { normaliseSourceInstance } from "@/lib/validations/inbound-documents";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -865,6 +866,11 @@ export async function restoreBackup(
|
||||
settings.assistantInsightStatusEnabled,
|
||||
assistantDocumentAiEnabled:
|
||||
settings.assistantDocumentAiEnabled ?? true,
|
||||
// v1.41 — absent on a file written before the controls existed,
|
||||
// which restores them at their defaults: allowed, no cap.
|
||||
aiReasoningEnabled: settings.aiReasoningEnabled ?? true,
|
||||
aiReasoningMaxEffort:
|
||||
settings.aiReasoningMaxEffort ?? DEFAULT_REASONING_MAX_EFFORT,
|
||||
moduleAvailabilityJson: coachFold.moduleAvailabilityJson as never,
|
||||
documentMaxFileBytes: settings.documentMaxFileBytes,
|
||||
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
|
||||
@@ -2475,6 +2481,11 @@ export async function restoreBackup(
|
||||
ownerId,
|
||||
payload,
|
||||
new Set(payload.coachConversations.map((c) => c.id)),
|
||||
new Set(
|
||||
payload.coachConversations.flatMap((c) =>
|
||||
c.messages.map((m) => m.id),
|
||||
),
|
||||
),
|
||||
skips,
|
||||
);
|
||||
|
||||
|
||||
@@ -698,4 +698,21 @@ describe("a Coach turn's stored tables are bounded on restore", () => {
|
||||
const sealed = "A".repeat(300 * 1024);
|
||||
expect(parseWithResults({ resultsEncrypted: sealed }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds the trail's text the same way, at its own 16 KB", () => {
|
||||
const fits = JSON.stringify({
|
||||
entries: [{ id: "a1", text: "x".repeat(15 * 1024) }],
|
||||
});
|
||||
expect(parseWithResults({ trailJson: fits }).success).toBe(true);
|
||||
expect(
|
||||
parseWithResults({ trailEncrypted: "A".repeat(30 * 1024) }).success,
|
||||
).toBe(true);
|
||||
const tooLong = JSON.stringify({
|
||||
entries: [{ id: "a1", text: "x".repeat(17 * 1024) }],
|
||||
});
|
||||
expect(parseWithResults({ trailJson: tooLong }).success).toBe(false);
|
||||
expect(
|
||||
parseWithResults({ trailEncrypted: "A".repeat(40 * 1024) }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_COACH_CLUSTERS,
|
||||
DEFAULT_COACH_PREFS,
|
||||
coachPrefsSchema,
|
||||
coachReasoningLevel,
|
||||
parseCoachPrefs,
|
||||
} from "../coach-prefs";
|
||||
|
||||
@@ -220,3 +221,39 @@ describe("parseCoachPrefs", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoning (v1.41)", () => {
|
||||
it("is absent on a legacy blob and reads as medium", () => {
|
||||
const prefs = parseCoachPrefs({ tone: "neutral" });
|
||||
expect(prefs).not.toHaveProperty("reasoning");
|
||||
expect(coachReasoningLevel(prefs)).toBe("medium");
|
||||
expect(coachReasoningLevel(DEFAULT_COACH_PREFS)).toBe("medium");
|
||||
});
|
||||
|
||||
it.each(["off", "low", "medium", "high"] as const)(
|
||||
"round-trips %s",
|
||||
(level) => {
|
||||
const prefs = parseCoachPrefs({ reasoning: level });
|
||||
expect(prefs.reasoning).toBe(level);
|
||||
expect(coachReasoningLevel(prefs)).toBe(level);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an unknown level on write", () => {
|
||||
expect(coachPrefsSchema.safeParse({ reasoning: "max" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops an unknown stored level alone and keeps every other field", () => {
|
||||
const prefs = parseCoachPrefs({
|
||||
reasoning: "xhigh",
|
||||
tone: "concise",
|
||||
excludeMetrics: ["weight"],
|
||||
});
|
||||
expect(prefs.reasoning).toBeUndefined();
|
||||
expect(coachReasoningLevel(prefs)).toBe("medium");
|
||||
expect(prefs.tone).toBe("concise");
|
||||
expect(prefs.excludeMetrics).toEqual(["weight"]);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs";
|
||||
import { TRAIL_MAX_BYTES } from "@/lib/ai/coach/activity/contract";
|
||||
import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels";
|
||||
import {
|
||||
AllergyCategory,
|
||||
AllergySeverity,
|
||||
@@ -1056,6 +1058,9 @@ const appSettingsBackupSchema = z
|
||||
// was retired still carries that key; `passthrough` keeps it harmless and
|
||||
// the restore writes nothing for it.
|
||||
assistantDocumentAiEnabled: z.boolean().optional(),
|
||||
// v1.41 — optional for the same reason.
|
||||
aiReasoningEnabled: z.boolean().optional(),
|
||||
aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(),
|
||||
moduleAvailabilityJson: z.unknown().nullable(),
|
||||
documentMaxFileBytes: z.number().int(),
|
||||
documentQuotaBytes: z.string().regex(/^\d+$/),
|
||||
@@ -1478,6 +1483,21 @@ const coachResultsSealedSchema = base64BytesSchema.refine(
|
||||
{ message: "Coach result tables exceed the stored size" },
|
||||
);
|
||||
|
||||
/** The trail's model text, bounded at restore like the tables above. */
|
||||
const COACH_TRAIL_SEALED_MAX_CHARS = 2 * TRAIL_MAX_BYTES;
|
||||
|
||||
const coachTrailJsonSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => new TextEncoder().encode(value).byteLength <= TRAIL_MAX_BYTES,
|
||||
{ message: `Coach trail exceeds ${TRAIL_MAX_BYTES} bytes` },
|
||||
);
|
||||
|
||||
const coachTrailSealedSchema = base64BytesSchema.refine(
|
||||
(value) => value.length <= COACH_TRAIL_SEALED_MAX_CHARS,
|
||||
{ message: "Coach trail exceeds the stored size" },
|
||||
);
|
||||
|
||||
/**
|
||||
* One Coach turn.
|
||||
*
|
||||
@@ -1502,6 +1522,9 @@ const coachMessageBackupSchema = z
|
||||
// readable JSON on a portable one, absent on every file written before.
|
||||
resultsEncrypted: coachResultsSealedSchema.nullable().optional(),
|
||||
resultsJson: coachResultsJsonSchema.nullable().optional(),
|
||||
// v1.41 — the trail's model text, the same two ends as the tables.
|
||||
trailEncrypted: coachTrailSealedSchema.nullable().optional(),
|
||||
trailJson: coachTrailJsonSchema.nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
})
|
||||
.passthrough();
|
||||
@@ -1549,6 +1572,11 @@ const coachFactBackupSchema = z
|
||||
category: z.string().min(1),
|
||||
confidence: z.number().int().min(0).max(100).optional(),
|
||||
sourceConversationId: z.string().nullable().optional(),
|
||||
// v1.41 — absent on every file written before; restored at the column
|
||||
// defaults then.
|
||||
source: z.string().min(1).max(32).optional(),
|
||||
lastUsedAt: isoDateTime.nullable().optional(),
|
||||
sourceMessageId: z.string().nullable().optional(),
|
||||
createdAt: isoDateTime,
|
||||
updatedAt: isoDateTime,
|
||||
deletedAt: isoDateTime.nullable().optional(),
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
DEFAULT_REASONING_LEVEL,
|
||||
REASONING_LEVELS,
|
||||
type ReasoningLevel,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
|
||||
/**
|
||||
* Tone presets the Coach system-prompt prefix toggles between. v1.4.22
|
||||
* landed `warm` as the default; `neutral` strips the warmth language
|
||||
@@ -196,6 +202,16 @@ export const coachPrefsSchema = z.object({
|
||||
.describe(
|
||||
"v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off.",
|
||||
),
|
||||
// v1.41 — how hard the Coach thinks before it answers. No schema default,
|
||||
// like the two keys above, so a legacy blob and `parse({})` stay
|
||||
// byte-identical; an absent key reads as `medium` (`coachReasoningLevel`).
|
||||
// The operator's switch and cap apply on top, server-side.
|
||||
reasoning: z
|
||||
.enum(REASONING_LEVELS)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET /api/auth/me` as `coachReasoning`.",
|
||||
),
|
||||
});
|
||||
|
||||
export type CoachPrefs = z.infer<typeof coachPrefsSchema>;
|
||||
@@ -214,6 +230,11 @@ export const DEFAULT_COACH_PREFS: CoachPrefs = {
|
||||
defaultWindow: "allTime",
|
||||
};
|
||||
|
||||
/** The person's reasoning level; an absent key is the default, `medium`. */
|
||||
export function coachReasoningLevel(prefs: CoachPrefs): ReasoningLevel {
|
||||
return prefs.reasoning ?? DEFAULT_REASONING_LEVEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`,
|
||||
* falling back to defaults when the row is null. Keeps the call sites at
|
||||
|
||||
Reference in new issue
Block a user