mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(coach): wire contract for steps, result tables, method, follow-ups and clarification
Adds the TypeScript types for the four additive stream frames and their provenance fields, a Zod mirror for the published document held equal to the types by a type test, and a guard that no newer frame reuses a key the shipped native client decodes. The chat request accepts followUp and clarification, inert for now.
This commit is contained in:
1 parent
620c538f9d
commit
cde7af3cf8
4 files changed
+922
No files matched your search
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* No Coach stream frame added after the native client shipped may carry a
|
||||
* top-level key that client already decodes.
|
||||
*
|
||||
* The native client decodes every frame into one flat struct —
|
||||
* `{ type, token, conversationId, messageId, code, message, suggestion,
|
||||
* metricSource, usage }` — and dispatches on `type`, ignoring a type it does
|
||||
* not know. That makes an unknown frame harmless only while its keys are
|
||||
* unknown too: a new frame that reused `message` or `usage` with a different
|
||||
* 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`).
|
||||
*
|
||||
* 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
|
||||
* TypeScript union is seen here too.
|
||||
*
|
||||
* Checked by breaking it: adding `message: z.string()` to the `step` frame in
|
||||
* `stream-events.ts` turns the second test red and names the frame and key.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { coachStreamEventSchema } from "@/lib/ai/coach/stream-events";
|
||||
|
||||
/** The keys the shipped native client decodes, `type` aside. */
|
||||
const NATIVE_CLIENT_KEYS = [
|
||||
"token",
|
||||
"conversationId",
|
||||
"messageId",
|
||||
"code",
|
||||
"message",
|
||||
"suggestion",
|
||||
"metricSource",
|
||||
"usage",
|
||||
] as const;
|
||||
|
||||
/** The frames the shipped client was built against; they own those keys. */
|
||||
const FRAMES_THE_CLIENT_KNOWS = new Set([
|
||||
"token",
|
||||
"provenance",
|
||||
"suggestion",
|
||||
"suggestedAction",
|
||||
"reasoning",
|
||||
"done",
|
||||
"error",
|
||||
]);
|
||||
|
||||
type FrameSchema = z.ZodObject<{ type: z.ZodLiteral<string> } & z.ZodRawShape>;
|
||||
|
||||
function frames(): Array<{ type: string; keys: string[] }> {
|
||||
return (coachStreamEventSchema.options as readonly FrameSchema[]).map(
|
||||
(option) => ({
|
||||
type: String(option.shape.type.value),
|
||||
keys: Object.keys(option.shape),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every `frame.key` pair where a newer frame reuses a native-client key. */
|
||||
function collisions(list: Array<{ type: string; keys: string[] }>): string[] {
|
||||
const reserved: ReadonlySet<string> = new Set(NATIVE_CLIENT_KEYS);
|
||||
return list
|
||||
.filter((frame) => !FRAMES_THE_CLIENT_KNOWS.has(frame.type))
|
||||
.flatMap((frame) =>
|
||||
frame.keys
|
||||
.filter((key) => reserved.has(key))
|
||||
.map((key) => `${frame.type}.${key}`),
|
||||
);
|
||||
}
|
||||
|
||||
describe("Coach stream frames stay decodable by the shipped native client", () => {
|
||||
it("reads every frame, including the ones added after the client shipped", () => {
|
||||
const types = frames().map((frame) => frame.type);
|
||||
// An empty or partial list would agree with anything.
|
||||
expect(types).toEqual(
|
||||
expect.arrayContaining([
|
||||
...FRAMES_THE_CLIENT_KNOWS,
|
||||
"step",
|
||||
"result",
|
||||
"followUps",
|
||||
"clarification",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
types.filter((type) => !FRAMES_THE_CLIENT_KNOWS.has(type)).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("no newer frame carries a key the native client decodes", () => {
|
||||
expect(
|
||||
collisions(frames()),
|
||||
"rename the payload key: the native client decodes these keys on every frame",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the check flags a colliding frame", () => {
|
||||
expect(
|
||||
collisions([
|
||||
{ type: "step", keys: ["type", "step", "message"] },
|
||||
{ type: "done", keys: ["type", "conversationId", "messageId"] },
|
||||
]),
|
||||
).toEqual(["step.message"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The Zod mirror of the Coach wire (`stream-events.ts`) and the TypeScript
|
||||
* contract (`types.ts`) describe the same shapes.
|
||||
*
|
||||
* The checks are compile-time: `expectTypeOf(...).toEqualTypeOf<...>()`
|
||||
* fails `pnpm typecheck` when a field is added, dropped, renamed or retyped
|
||||
* on one side only. Equality is exact in both directions, so a field that
|
||||
* is optional on one side and required on the other fails as well.
|
||||
*
|
||||
* Checked by breaking it: removing `resultRef` from `coachStepSchema`, and
|
||||
* making `CoachFollowUp.reuse` optional in `types.ts`, each fail the
|
||||
* typecheck on the matching line below.
|
||||
*/
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type {
|
||||
CoachClarification,
|
||||
CoachFollowUp,
|
||||
CoachMethod,
|
||||
CoachProvenance,
|
||||
CoachResultEntry,
|
||||
CoachResultMeta,
|
||||
CoachResultTable,
|
||||
CoachStep,
|
||||
CoachStreamEvent,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import {
|
||||
coachClarificationSchema,
|
||||
coachFollowUpSchema,
|
||||
coachMethodSchema,
|
||||
coachProvenanceSchema,
|
||||
coachResultEntrySchema,
|
||||
coachResultMetaSchema,
|
||||
coachResultTableSchema,
|
||||
coachStepSchema,
|
||||
coachStreamEventSchema,
|
||||
} from "@/lib/ai/coach/stream-events";
|
||||
|
||||
describe("Coach wire: Zod mirror equals the TypeScript contract", () => {
|
||||
it("every frame", () => {
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachStreamEventSchema>
|
||||
>().toEqualTypeOf<CoachStreamEvent>();
|
||||
});
|
||||
|
||||
it("each component the OpenAPI document names", () => {
|
||||
expectTypeOf<z.infer<typeof coachStepSchema>>().toEqualTypeOf<CoachStep>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachResultMetaSchema>
|
||||
>().toEqualTypeOf<CoachResultMeta>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachResultTableSchema>
|
||||
>().toEqualTypeOf<CoachResultTable>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachResultEntrySchema>
|
||||
>().toEqualTypeOf<CoachResultEntry>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachMethodSchema>
|
||||
>().toEqualTypeOf<CoachMethod>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachFollowUpSchema>
|
||||
>().toEqualTypeOf<CoachFollowUp>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachClarificationSchema>
|
||||
>().toEqualTypeOf<CoachClarification>();
|
||||
expectTypeOf<
|
||||
z.infer<typeof coachProvenanceSchema>
|
||||
>().toEqualTypeOf<CoachProvenance>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* The Coach chat wire as Zod schemas.
|
||||
*
|
||||
* `types.ts` holds the TypeScript contract the server and the web client
|
||||
* compile against; this file is its mirror for the published OpenAPI
|
||||
* document (`docs/api/openapi.yaml`), which the native client reads. The two
|
||||
* are held equal by `__tests__/stream-events-type-parity.test.ts`: a field
|
||||
* added on one side and not the other fails the typecheck.
|
||||
*
|
||||
* Nothing here parses a live frame. The server builds frames from typed
|
||||
* values and the client dispatches on `type`; these schemas describe the
|
||||
* shapes, they do not police them.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AI_UNAVAILABLE_REASONS } from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
SUGGESTED_ACTION_TYPES,
|
||||
type CheckupIntervalId,
|
||||
} from "@/lib/ai/coach/suggest-action";
|
||||
import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions";
|
||||
import {
|
||||
coachKeyValueSchema,
|
||||
coachScopeSourceSchema,
|
||||
coachScopeWindowSchema,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
const coachWindowEnum = coachScopeWindowSchema;
|
||||
const coachToolNameEnum = z.enum(COACH_TOOL_NAMES);
|
||||
|
||||
export const coachProvenanceMetricSchema = z.enum([
|
||||
...coachScopeSourceSchema.options,
|
||||
"general",
|
||||
]);
|
||||
|
||||
export const coachStepDomainSchema = z
|
||||
.enum([
|
||||
...coachScopeSourceSchema.options,
|
||||
"labs",
|
||||
"illness",
|
||||
"cycle",
|
||||
"correlations",
|
||||
"snapshot",
|
||||
])
|
||||
.meta({
|
||||
id: "CoachStepDomain",
|
||||
description:
|
||||
"The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a whole (labs, illness, cycle, correlations, the full snapshot).",
|
||||
});
|
||||
|
||||
const coachResultGranularitySchema = z.enum(["day", "week", "month"]);
|
||||
const coachResultPeriodSchema = z.enum(["current", "previous", "yearAgo"]);
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const coachStepSchema = z
|
||||
.object({
|
||||
id: z.string().describe("`s1`..`s12`, unique within a turn."),
|
||||
tool: z.enum([...COACH_TOOL_NAMES, "show_result", "snapshot"]),
|
||||
labelKey: z
|
||||
.string()
|
||||
.describe("Closed catalog key (`coach.step.*`) the client localises."),
|
||||
label: z
|
||||
.string()
|
||||
.describe(
|
||||
"The label rendered on the server in the request locale. Render it when the client has no string for `labelKey`.",
|
||||
),
|
||||
domain: coachStepDomainSchema.optional(),
|
||||
window: coachWindowEnum.optional(),
|
||||
period: coachResultPeriodSchema.optional(),
|
||||
granularity: coachResultGranularitySchema.optional(),
|
||||
status: z.enum(["running", "done", "empty", "failed"]),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe("Readings or rows the server counted. Never a health value."),
|
||||
reason: z
|
||||
.enum([
|
||||
"no_data",
|
||||
"outside_window",
|
||||
"module_disabled",
|
||||
"retrieval_failed",
|
||||
"invalid_arguments",
|
||||
])
|
||||
.optional(),
|
||||
resultRef: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The table this step produced (`r1`..), when it produced one."),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachStep",
|
||||
description:
|
||||
"One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value.",
|
||||
});
|
||||
|
||||
// ── Results ───────────────────────────────────────────────────────────────
|
||||
|
||||
const coachResultColumnSchema = z.object({
|
||||
key: z.string(),
|
||||
kind: z.enum(["period", "category", "number", "count"]),
|
||||
labelKey: z.string(),
|
||||
label: z.string(),
|
||||
unit: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Display unit token such as `mmHg`, never free text."),
|
||||
decimals: z.number().int().optional(),
|
||||
});
|
||||
|
||||
const coachChartSpecSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("line"),
|
||||
x: z.string(),
|
||||
series: z.array(z.string()),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("bar"),
|
||||
x: z.string(),
|
||||
series: z.array(z.string()),
|
||||
orientation: z.enum(["vertical", "horizontal"]),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("histogram"),
|
||||
column: z.string(),
|
||||
unit: z.string().optional(),
|
||||
bins: z.array(
|
||||
z.object({ from: z.number(), to: z.number(), count: z.number().int() }),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
const coachResultSourceSchema = z.object({
|
||||
tool: coachToolNameEnum,
|
||||
domain: coachStepDomainSchema,
|
||||
window: coachWindowEnum,
|
||||
period: coachResultPeriodSchema,
|
||||
granularity: coachResultGranularitySchema.optional(),
|
||||
});
|
||||
|
||||
const coachResultMetaShape = {
|
||||
ref: z.string().describe("`r1`..`r6`, 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(),
|
||||
displayed: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the answer referenced the table (shown expanded); false when it sits under the data-used disclosure.",
|
||||
),
|
||||
reusedFrom: z
|
||||
.object({ messageId: z.string(), ref: z.string() })
|
||||
.optional()
|
||||
.describe("Set when the table was copied from an earlier message."),
|
||||
};
|
||||
|
||||
export const coachResultMetaSchema = z.object(coachResultMetaShape).meta({
|
||||
id: "CoachResultMeta",
|
||||
description:
|
||||
"A table's metadata, persisted in the plaintext provenance (`metricSource.results`). The values are not here: fetch them from `GET /api/insights/chat/{id}/messages/{messageId}/results`.",
|
||||
});
|
||||
|
||||
export const coachResultTableSchema = z
|
||||
.object({
|
||||
...coachResultMetaShape,
|
||||
columns: z.array(coachResultColumnSchema),
|
||||
rows: z
|
||||
.array(z.array(z.union([z.string(), z.number(), z.null()])))
|
||||
.describe(
|
||||
"Row-major cells, at most 400 rows. `null` is a period with no reading.",
|
||||
),
|
||||
truncated: z.boolean().describe("True when `rowCount` exceeds the rows."),
|
||||
chart: coachChartSpecSchema
|
||||
.nullable()
|
||||
.describe("The chart the server chose for the table, or null."),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachResultTable",
|
||||
description:
|
||||
"A table of the values a turn read. Health data: encrypted at rest and sent only to the account that owns the conversation.",
|
||||
});
|
||||
|
||||
const coachResultWithheldSchema = z.object({
|
||||
ref: z.string(),
|
||||
withheld: z
|
||||
.enum(["module_disabled", "unavailable"])
|
||||
.describe(
|
||||
"`module_disabled`: the table's domain is switched off for this record. `unavailable`: the stored tables could not be read.",
|
||||
),
|
||||
});
|
||||
|
||||
export const coachResultEntrySchema = z
|
||||
.union([coachResultTableSchema, coachResultWithheldSchema])
|
||||
.meta({
|
||||
id: "CoachResultEntry",
|
||||
description:
|
||||
"One stored table, or the reason it is not served. Absence is explicit: a withheld table names why.",
|
||||
});
|
||||
|
||||
// ── Method ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const coachMethodSchema = z
|
||||
.object({
|
||||
entries: z.array(
|
||||
z.object({
|
||||
domain: coachStepDomainSchema,
|
||||
window: coachWindowEnum.optional(),
|
||||
period: coachResultPeriodSchema.optional(),
|
||||
granularity: coachResultGranularitySchema.optional(),
|
||||
count: z.number().int().optional(),
|
||||
aggregation: z
|
||||
.enum(["mean", "median", "latest", "sum", "count", "rate"])
|
||||
.optional(),
|
||||
absent: z
|
||||
.enum(["no_data", "outside_window", "module_disabled"])
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
text: z.string().describe("Rendered on the server in the request locale."),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachMethod",
|
||||
description:
|
||||
"How an answer was reached: which sources, windows, counts and aggregation. Never a health value.",
|
||||
});
|
||||
|
||||
// ── Follow-ups ────────────────────────────────────────────────────────────
|
||||
|
||||
export const coachFollowUpSchema = z
|
||||
.object({
|
||||
id: z.string().describe("`f1`..`f3`."),
|
||||
kind: z.enum([
|
||||
"widen_window",
|
||||
"previous_period",
|
||||
"year_ago",
|
||||
"as_chart",
|
||||
"as_table",
|
||||
"related_metric",
|
||||
"continue",
|
||||
]),
|
||||
labelKey: z.string(),
|
||||
label: z
|
||||
.string()
|
||||
.describe("Rendered on the server from a catalog; never model text."),
|
||||
anchor: z
|
||||
.object({
|
||||
ref: z.string(),
|
||||
domain: coachStepDomainSchema,
|
||||
window: coachWindowEnum.optional(),
|
||||
granularity: coachResultGranularitySchema.optional(),
|
||||
period: coachResultPeriodSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
reuse: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the chip is answered from a stored table without a model call.",
|
||||
),
|
||||
origin: z.enum(["server", "model"]),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachFollowUp",
|
||||
description:
|
||||
"A chip offered under the latest assistant reply. Send it back as `followUp: { messageId, id }` with the chip's label as `message`.",
|
||||
});
|
||||
|
||||
// ── Clarification ─────────────────────────────────────────────────────────
|
||||
|
||||
export const coachClarificationSchema = z
|
||||
.object({
|
||||
kind: z.enum(["metric", "window", "context"]),
|
||||
choices: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
labelKey: z.string(),
|
||||
label: z.string(),
|
||||
value: z.object({
|
||||
metric: coachScopeSourceSchema.optional(),
|
||||
window: coachWindowEnum.optional(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.describe("At most 4. Metric choices are always ones the record holds."),
|
||||
freeText: z.boolean().describe("Whether a typed answer is welcome."),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachClarification",
|
||||
description:
|
||||
"The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`.",
|
||||
});
|
||||
|
||||
// ── The cards and the usage envelope ──────────────────────────────────────
|
||||
|
||||
const coachSuggestionSchema = z
|
||||
.object({
|
||||
cadenceId: z.string(),
|
||||
measurementType: z.string(),
|
||||
label: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachSuggestion",
|
||||
description:
|
||||
"A cadence-suggestion card. Accepting it posts `cadenceId` to `POST /api/measurement-reminders`.",
|
||||
});
|
||||
|
||||
const checkupIntervalSchema = z.string() as z.ZodType<CheckupIntervalId>;
|
||||
|
||||
const coachSuggestedActionSchema = z
|
||||
.object({
|
||||
actionType: z.enum(SUGGESTED_ACTION_TYPES),
|
||||
summary: z.string(),
|
||||
titleKey: z.string(),
|
||||
params: z.discriminatedUnion("actionType", [
|
||||
z.object({
|
||||
actionType: z.literal("checkup.create"),
|
||||
label: z.string(),
|
||||
interval: checkupIntervalSchema,
|
||||
}),
|
||||
z.object({
|
||||
actionType: z.literal("reminder.note"),
|
||||
note: z.string(),
|
||||
when: z.string().optional(),
|
||||
metric: z.string().optional(),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachSuggestedAction",
|
||||
description:
|
||||
"A confirm-to-apply action card from a closed allowlist. Nothing is created until the person confirms it through `POST /api/coach/suggested-actions`.",
|
||||
});
|
||||
|
||||
const coachUsageSchema = z
|
||||
.object({
|
||||
totalTokens: z.number().int().nullable(),
|
||||
promptTokens: z.number().int().nullable().optional(),
|
||||
completionTokens: z.number().int().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachUsage",
|
||||
description:
|
||||
"Per-turn token usage. Server-authoritative: clients display it, never recompute it.",
|
||||
});
|
||||
|
||||
// ── Provenance ────────────────────────────────────────────────────────────
|
||||
|
||||
export const coachProvenanceSchema = z
|
||||
.object({
|
||||
windows: z
|
||||
.array(coachWindowEnum)
|
||||
.readonly()
|
||||
.describe("Analysis windows the assistant drew on this turn."),
|
||||
metrics: z
|
||||
.array(coachProvenanceMetricSchema)
|
||||
.readonly()
|
||||
.describe(
|
||||
"Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot sentinel. The client translates these labels; the server never localises them.",
|
||||
),
|
||||
counts: z
|
||||
.partialRecord(coachProvenanceMetricSchema, z.number().int())
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-metric sample-count summary; absent on an empty snapshot.",
|
||||
),
|
||||
keyValues: z
|
||||
.array(coachKeyValueSchema)
|
||||
.readonly()
|
||||
.optional()
|
||||
.describe(
|
||||
"Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.",
|
||||
),
|
||||
suggestion: coachSuggestionSchema
|
||||
.optional()
|
||||
.describe("The cadence-suggestion card this turn carried, if any."),
|
||||
suggestedAction: coachSuggestedActionSchema
|
||||
.optional()
|
||||
.describe("The confirm-to-apply action card this turn carried, if any."),
|
||||
toolCalls: z
|
||||
.array(z.object({ name: z.string(), present: z.boolean() }))
|
||||
.readonly()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools.",
|
||||
),
|
||||
groundedFigures: z
|
||||
.array(z.number())
|
||||
.readonly()
|
||||
.optional()
|
||||
.describe(
|
||||
"Bare magnitudes the turn's tools returned, recalled by a later turn's number check. Server-computed, label-less.",
|
||||
),
|
||||
unverifiedFigures: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent when the turn withheld nothing.",
|
||||
),
|
||||
steps: z
|
||||
.array(coachStepSchema)
|
||||
.optional()
|
||||
.describe("v1.39.4 — what the Coach read on this turn."),
|
||||
method: coachMethodSchema
|
||||
.optional()
|
||||
.describe("v1.39.4 — how the answer was reached."),
|
||||
results: z
|
||||
.array(coachResultMetaSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.39.4 — the tables this turn produced, metadata only; the values are behind the results endpoint.",
|
||||
),
|
||||
followUps: z
|
||||
.array(coachFollowUpSchema)
|
||||
.optional()
|
||||
.describe("v1.39.4 — the chips offered under this reply."),
|
||||
clarification: coachClarificationSchema
|
||||
.optional()
|
||||
.describe("v1.39.4 — the choices, when this reply is a question."),
|
||||
forcedFinal: z
|
||||
.literal(true)
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachProvenance",
|
||||
description:
|
||||
"Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values and cards. No raw timestamps and no table values.",
|
||||
});
|
||||
|
||||
// ── Frames ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const coachStreamEventSchema = z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("token"), token: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("provenance"),
|
||||
metricSource: coachProvenanceSchema,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("suggestion"),
|
||||
suggestion: coachSuggestionSchema,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("suggestedAction"),
|
||||
suggestedAction: coachSuggestedActionSchema,
|
||||
}),
|
||||
z.object({ type: z.literal("reasoning"), text: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("done"),
|
||||
conversationId: z.string(),
|
||||
messageId: z.string(),
|
||||
usage: coachUsageSchema.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("error"),
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
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("followUps"),
|
||||
followUps: z.array(coachFollowUpSchema),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("clarification"),
|
||||
clarification: coachClarificationSchema,
|
||||
}),
|
||||
])
|
||||
.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.",
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
|
||||
import type { CoachSuggestedAction } from "./suggest-action";
|
||||
import type { CoachToolName } from "./tools/definitions";
|
||||
|
||||
/**
|
||||
* Chat-message role. Stored as a free-form string column server-side
|
||||
@@ -157,6 +158,31 @@ export const coachChatRequestSchema = z.object({
|
||||
* turn, so per-turn work does not grow with conversation length.
|
||||
*/
|
||||
workoutId: z.string().max(64).optional(),
|
||||
/**
|
||||
* v1.39.4 — the person tapped a follow-up chip. `messageId` is the
|
||||
* assistant message that offered it and `id` the chip on that message
|
||||
* (`f1`..`f3`). The server resolves the chip from what it persisted on
|
||||
* the conversation's latest assistant message; the client never sends
|
||||
* what the chip asks for, only which chip it was. A chip that is no
|
||||
* longer current degrades to a plain message.
|
||||
*/
|
||||
followUp: z
|
||||
.object({
|
||||
messageId: z.string().min(1).max(64),
|
||||
id: z.string().min(1).max(8),
|
||||
})
|
||||
.optional(),
|
||||
/**
|
||||
* v1.39.4 — the person answered a clarifying question. `messageId` is
|
||||
* the assistant message that asked it; `choiceId` the chip they tapped,
|
||||
* absent when they typed their own answer instead.
|
||||
*/
|
||||
clarification: z
|
||||
.object({
|
||||
messageId: z.string().min(1).max(64),
|
||||
choiceId: z.string().min(1).max(8).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -212,6 +238,21 @@ export interface CoachUsage {
|
||||
* inside the thinking disclosure when a reasoning-capable provider
|
||||
* emits a cheap reasoning summary. Providers without reasoning emit
|
||||
* none, and the disclosure falls back to its elapsed-time label.
|
||||
*
|
||||
* v1.39.4 — four additive frames: `step` (live progress while the Coach
|
||||
* reads the record), `result` (a table of the values it read, owner only),
|
||||
* `followUps` (chips under the latest reply) and `clarification` (a
|
||||
* question with choices). Frame order on a turn:
|
||||
*
|
||||
* step* → token* → provenance → result* → suggestion? →
|
||||
* suggestedAction? → 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.
|
||||
* `src/__tests__/coach-stream-ios-compat.test.ts` holds that rule, and the
|
||||
* Zod mirror in `stream-events.ts` is held equal to this union by a type
|
||||
* test.
|
||||
*/
|
||||
export type CoachStreamEvent =
|
||||
| { type: "token"; token: string }
|
||||
@@ -238,8 +279,205 @@ export type CoachStreamEvent =
|
||||
* ignore it.
|
||||
*/
|
||||
reason?: AiUnavailableReason;
|
||||
}
|
||||
| { type: "step"; step: CoachStep }
|
||||
| { type: "result"; result: CoachResultTable }
|
||||
| { type: "followUps"; followUps: CoachFollowUp[] }
|
||||
| { type: "clarification"; clarification: CoachClarification };
|
||||
|
||||
// ── Steps (v1.39.4) ─────────────────────────────────────────────────────
|
||||
// What the Coach is reading while a turn runs. Labels come from a closed
|
||||
// catalog; a step never carries free text, an analyte name, or a value.
|
||||
|
||||
export type CoachStepStatus = "running" | "done" | "empty" | "failed";
|
||||
|
||||
/**
|
||||
* The data domain a step, result or chip is about. The measurement-backed
|
||||
* scope sources plus the domains that are read as a whole.
|
||||
*/
|
||||
export type CoachStepDomain =
|
||||
CoachScopeSource | "labs" | "illness" | "cycle" | "correlations" | "snapshot";
|
||||
|
||||
/** Why a step or a method entry found nothing. */
|
||||
export type CoachStepReason =
|
||||
| "no_data"
|
||||
| "outside_window"
|
||||
| "module_disabled"
|
||||
| "retrieval_failed"
|
||||
| "invalid_arguments";
|
||||
|
||||
export interface CoachStep {
|
||||
/** `s1`..`s12`, unique within a turn; frames upsert by id. */
|
||||
id: string;
|
||||
tool: CoachToolName | "show_result" | "snapshot";
|
||||
/** Closed catalog key, e.g. `coach.step.read`. */
|
||||
labelKey: string;
|
||||
/** The label rendered on the server in the request locale; the fallback. */
|
||||
label: string;
|
||||
domain?: CoachStepDomain;
|
||||
window?: CoachScopeWindow;
|
||||
period?: CoachResultPeriod;
|
||||
granularity?: CoachResultGranularity;
|
||||
status: CoachStepStatus;
|
||||
/** Readings or rows the server counted. Never a health value. */
|
||||
count?: number;
|
||||
reason?: CoachStepReason;
|
||||
/** `r1` when the step produced a table. */
|
||||
resultRef?: string;
|
||||
}
|
||||
|
||||
// ── Results (v1.39.4) ───────────────────────────────────────────────────
|
||||
// A table of the values a turn read. The values are health data: encrypted
|
||||
// at rest (`coach_messages.results_encrypted`) and sent only to the owner.
|
||||
// The metadata alone rides the plaintext provenance.
|
||||
|
||||
export type CoachResultGranularity = "day" | "week" | "month";
|
||||
export type CoachResultPeriod = "current" | "previous" | "yearAgo";
|
||||
export type CoachResultShape =
|
||||
"timeSeries" | "categoryCounts" | "distribution" | "single";
|
||||
|
||||
export interface CoachResultColumn {
|
||||
key: string;
|
||||
kind: "period" | "category" | "number" | "count";
|
||||
labelKey: string;
|
||||
label: string;
|
||||
/** Display unit token (e.g. `mmHg`), never free text. */
|
||||
unit?: string;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
/** `null` is a period with no reading: absence stays explicit. */
|
||||
export type CoachResultCell = string | number | null;
|
||||
|
||||
export type CoachChartSpec =
|
||||
| { kind: "line"; x: string; series: string[] }
|
||||
| {
|
||||
kind: "bar";
|
||||
x: string;
|
||||
series: string[];
|
||||
orientation: "vertical" | "horizontal";
|
||||
}
|
||||
| {
|
||||
kind: "histogram";
|
||||
column: string;
|
||||
unit?: string;
|
||||
bins: Array<{ from: number; to: number; count: number }>;
|
||||
};
|
||||
|
||||
export interface CoachResultSource {
|
||||
tool: CoachToolName;
|
||||
domain: CoachStepDomain;
|
||||
window: CoachScopeWindow;
|
||||
period: CoachResultPeriod;
|
||||
granularity?: CoachResultGranularity;
|
||||
}
|
||||
|
||||
/** Plaintext; rides `metricSource.results`. */
|
||||
export interface CoachResultMeta {
|
||||
/** `r1`..`r6`, per message. */
|
||||
ref: string;
|
||||
source: CoachResultSource;
|
||||
shape: CoachResultShape;
|
||||
titleKey: string;
|
||||
title: string;
|
||||
/** The full row count, before any trim. */
|
||||
rowCount: number;
|
||||
chartKind: CoachChartSpec["kind"] | null;
|
||||
/** The answer referenced it (shown expanded) or it sits under "Data used". */
|
||||
displayed: boolean;
|
||||
/** Set when the table was copied from an earlier message of the thread. */
|
||||
reusedFrom?: { messageId: string; ref: string };
|
||||
}
|
||||
|
||||
/** Encrypted at rest; on the wire only to the owner. */
|
||||
export interface CoachResultTable extends CoachResultMeta {
|
||||
columns: CoachResultColumn[];
|
||||
/** Row-major, at most 400 rows. */
|
||||
rows: CoachResultCell[][];
|
||||
/** True when `rowCount` exceeds `rows.length`. */
|
||||
truncated: boolean;
|
||||
chart: CoachChartSpec | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of `GET …/messages/{messageId}/results`: the table, or the
|
||||
* reason it is not served. `module_disabled` — the domain's module is now
|
||||
* off for the record; `unavailable` — the stored tables could not be read.
|
||||
*/
|
||||
export type CoachResultEntry =
|
||||
| CoachResultTable
|
||||
| { ref: string; withheld: "module_disabled" | "unavailable" };
|
||||
|
||||
// ── Method (v1.39.4) ────────────────────────────────────────────────────
|
||||
// How the answer was reached: sources, windows, counts, aggregation. Never
|
||||
// a health value.
|
||||
|
||||
export interface CoachMethodEntry {
|
||||
domain: CoachStepDomain;
|
||||
window?: CoachScopeWindow;
|
||||
period?: CoachResultPeriod;
|
||||
granularity?: CoachResultGranularity;
|
||||
count?: number;
|
||||
aggregation?: "mean" | "median" | "latest" | "sum" | "count" | "rate";
|
||||
absent?: "no_data" | "outside_window" | "module_disabled";
|
||||
}
|
||||
|
||||
export interface CoachMethod {
|
||||
entries: CoachMethodEntry[];
|
||||
/** Rendered on the server in the request locale. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ── Follow-ups (v1.39.4) ────────────────────────────────────────────────
|
||||
// Chips under the latest assistant reply. The label is always rendered by
|
||||
// the server from a catalog; the model may at most pick a kind and domain.
|
||||
|
||||
export type CoachFollowUpKind =
|
||||
| "widen_window"
|
||||
| "previous_period"
|
||||
| "year_ago"
|
||||
| "as_chart"
|
||||
| "as_table"
|
||||
| "related_metric"
|
||||
| "continue";
|
||||
|
||||
export interface CoachFollowUp {
|
||||
/** `f1`..`f3`. */
|
||||
id: string;
|
||||
kind: CoachFollowUpKind;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
anchor?: {
|
||||
ref: string;
|
||||
domain: CoachStepDomain;
|
||||
window?: CoachScopeWindow;
|
||||
granularity?: CoachResultGranularity;
|
||||
period?: CoachResultPeriod;
|
||||
};
|
||||
/** True when the chip is answered from a stored table, without a model. */
|
||||
reuse: boolean;
|
||||
origin: "server" | "model";
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
|
||||
export interface CoachClarificationChoice {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
value: { metric?: CoachScopeSource; window?: CoachScopeWindow };
|
||||
}
|
||||
|
||||
export interface CoachClarification {
|
||||
kind: "metric" | "window" | "context";
|
||||
/** 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.4.22 — Zod schema for one entry inside the Coach's evidence
|
||||
* block. The model emits these as lines between the
|
||||
@@ -399,6 +637,29 @@ export interface CoachProvenance {
|
||||
* turn withheld nothing.
|
||||
*/
|
||||
unverifiedFigures?: number;
|
||||
/**
|
||||
* v1.39.4 — what the Coach read on this turn, one entry per tool call (or
|
||||
* one `snapshot` step on the no-tools path). Catalog labels, domains,
|
||||
* windows and counts only.
|
||||
*/
|
||||
steps?: CoachStep[];
|
||||
/** v1.39.4 — how the answer was reached, as a server-rendered line. */
|
||||
method?: CoachMethod;
|
||||
/**
|
||||
* v1.39.4 — the tables this turn produced, metadata only. The values are
|
||||
* in `results_encrypted` and served by
|
||||
* `GET /api/insights/chat/{id}/messages/{messageId}/results`.
|
||||
*/
|
||||
results?: CoachResultMeta[];
|
||||
/** v1.39.4 — the chips offered under this reply. */
|
||||
followUps?: CoachFollowUp[];
|
||||
/** v1.39.4 — the choices, when this reply is a clarifying question. */
|
||||
clarification?: CoachClarification;
|
||||
/**
|
||||
* v1.39.4 — the tool loop hit its round cap while the model still wanted
|
||||
* to read, so the answer was forced. Absent otherwise.
|
||||
*/
|
||||
forcedFinal?: true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user