mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(coach): serve a message's stored tables and publish the stream contract
GET /api/insights/chat/{id}/messages/{messageId}/results returns the
tables one assistant message read, narrowed to the conversation's owner
like the conversation read, with a table withheld as module_disabled when
its domain's module is off and as unavailable when it cannot be read. A
data read: no AI capability.
The published document now describes every stream frame as the
CoachStreamEvent union, including the suggestedAction frame it never
named, the new step, result, followUps and clarification frames, their
components, the provenance fields, the followUp and clarification request
fields, and the reuse provider type.
This commit is contained in:
1 parent
8fcd204172
commit
ad72ae3e4c
7 files changed
+1523
-167
No files matched your search
+1286
-95
File diff suppressed because it is too large.
Load diff
@@ -217,6 +217,8 @@ export const DATA_ROUTES: Readonly<Record<string, string>> = {
|
||||
"One stored conversation: read, rename, delete.",
|
||||
"src/app/api/insights/chat/messages/[id]/feedback/route.ts":
|
||||
"A rating on a message that already exists.",
|
||||
"src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts":
|
||||
"The stored tables of one message: the person's record, withheld per module, never per AI capability.",
|
||||
"src/app/api/insights/breathing-screening/route.ts":
|
||||
"Device breathing data, owned by the sleep module.",
|
||||
"src/app/api/insights/labs-changes/route.ts":
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type * as ApiHandlerModule from "@/lib/api-handler";
|
||||
|
||||
const { requireAuth, readMessageResults, resolveModuleMap } = vi.hoisted(
|
||||
() => ({
|
||||
requireAuth: vi.fn(),
|
||||
readMessageResults: vi.fn(),
|
||||
resolveModuleMap: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api-handler", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ApiHandlerModule>();
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
|
||||
handler,
|
||||
requireAuth,
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap }));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageResults }));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const USER_ID = "user-1";
|
||||
|
||||
function call(id = "c1", messageId = "m1") {
|
||||
return GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/insights/chat/${id}/messages/${messageId}/results`,
|
||||
),
|
||||
{ params: Promise.resolve({ id, messageId }) },
|
||||
) as Promise<Response>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAuth.mockResolvedValue({ user: { id: USER_ID } });
|
||||
resolveModuleMap.mockResolvedValue({ sleep: false, mood: true });
|
||||
});
|
||||
|
||||
describe("GET /api/insights/chat/[id]/messages/[messageId]/results", () => {
|
||||
it("reads under the caller's own id and the path ids only", async () => {
|
||||
readMessageResults.mockResolvedValue([]);
|
||||
const response = await call("c1", "m1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(readMessageResults).toHaveBeenCalledWith(
|
||||
USER_ID,
|
||||
"c1",
|
||||
"m1",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(await response.json()).toEqual({
|
||||
data: { results: [] },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 404 for a message the caller does not own", async () => {
|
||||
readMessageResults.mockResolvedValue(null);
|
||||
const response = await call();
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("withholds a domain whose module is switched off, and only that one", async () => {
|
||||
readMessageResults.mockResolvedValue([]);
|
||||
await call();
|
||||
const withhold = readMessageResults.mock.calls[0][3] as (
|
||||
domain: string,
|
||||
) => boolean;
|
||||
expect(withhold("sleep")).toBe(true);
|
||||
expect(withhold("mood")).toBe(false);
|
||||
// No module owns blood pressure or labs: never withheld.
|
||||
expect(withhold("bp")).toBe(false);
|
||||
expect(withhold("labs")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses an unauthenticated caller before any read", async () => {
|
||||
requireAuth.mockRejectedValue(new Error("Not authenticated"));
|
||||
await expect(call()).rejects.toThrow("Not authenticated");
|
||||
expect(readMessageResults).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* GET /api/insights/chat/[id]/messages/[messageId]/results — the tables of
|
||||
* values one assistant message read, decrypted for their owner.
|
||||
*
|
||||
* A data read, never AI-gated: the tables are the person's stored record,
|
||||
* readable while the Coach is unavailable for any reason, like the
|
||||
* conversation they belong to. Narrowed exactly like
|
||||
* `GET /api/insights/chat/[id]`: the message must sit in a conversation the
|
||||
* caller owns, and a foreign or unknown id is 404, never 403.
|
||||
*
|
||||
* Each table is served whole or withheld with a reason: `module_disabled`
|
||||
* when its domain's module is switched off for the record now (the table is
|
||||
* not even decrypted), `unavailable` when the stored tables cannot be read.
|
||||
* The list follows the order of `metricSource.results` on the message.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { readMessageResults } from "@/lib/ai/coach/persistence";
|
||||
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string; messageId: string }>;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const auth = await requireAuth();
|
||||
const userId = auth.user.id;
|
||||
const { id, messageId } = await ctx.params;
|
||||
if (!id || !messageId) return apiError("coach.message.notFound", 404);
|
||||
|
||||
const modules = await resolveModuleMap(userId);
|
||||
const results = await readMessageResults(userId, id, messageId, (domain) =>
|
||||
isCoachDomainWithheld(domain, modules),
|
||||
);
|
||||
if (!results) return apiError("coach.message.notFound", 404);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.coach.results.read" },
|
||||
meta: {
|
||||
conversationId: id,
|
||||
tables: results.length,
|
||||
withheld: results.filter((entry) => "withheld" in entry).length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ results });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Which module owns the domain a stored Coach table is about, so a table
|
||||
* read after the module was switched off is withheld rather than served.
|
||||
*
|
||||
* The same ownership the Coach applies when it reads: the measurement-level
|
||||
* table in `MODULE_SCOPED_SOURCES`, plus the cycle and illness domains,
|
||||
* which the Coach gates on their own modules. Labs, medication compliance,
|
||||
* correlations and the full snapshot answer to no module there, so none
|
||||
* here either — gating them on this read would make a stored answer
|
||||
* stricter than the Coach that wrote it.
|
||||
*/
|
||||
import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { CoachStepDomain } from "@/lib/ai/coach/types";
|
||||
|
||||
const OWNER_BY_DOMAIN: ReadonlyMap<string, ModuleKey> = (() => {
|
||||
const out = new Map<string, ModuleKey>([
|
||||
["cycle", "cycle"],
|
||||
["illness", "illness"],
|
||||
]);
|
||||
for (const [key, sources] of Object.entries(MODULE_SCOPED_SOURCES)) {
|
||||
for (const source of sources ?? []) out.set(source, key as ModuleKey);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
export function moduleForCoachDomain(
|
||||
domain: CoachStepDomain,
|
||||
): ModuleKey | null {
|
||||
return OWNER_BY_DOMAIN.get(domain) ?? null;
|
||||
}
|
||||
|
||||
/** True when the domain's module is switched off in `modules`. */
|
||||
export function isCoachDomainWithheld(
|
||||
domain: CoachStepDomain,
|
||||
modules: Readonly<Record<ModuleKey, boolean>>,
|
||||
): boolean {
|
||||
const owner = moduleForCoachDomain(domain);
|
||||
return owner !== null && modules[owner] === false;
|
||||
}
|
||||
@@ -330,7 +330,7 @@ const coachSuggestedActionSchema = z
|
||||
]),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachSuggestedAction",
|
||||
id: "CoachSuggestedActionCard",
|
||||
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`.",
|
||||
});
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
coachPlanPatchSchema,
|
||||
} from "@/lib/validations/coach-plan";
|
||||
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
coachProvenanceSchema,
|
||||
coachResultEntrySchema,
|
||||
coachStreamEventSchema,
|
||||
} from "@/lib/ai/coach/stream-events";
|
||||
import {
|
||||
coachAttachmentCreateSchema,
|
||||
fencedChatRequestSchema,
|
||||
@@ -468,68 +473,8 @@ const coachSuggestedActionResultResponse = z
|
||||
// endpoint decrypts every message server-side, so the client never
|
||||
// handles a key. The list endpoint stays metadata-only (no decryption).
|
||||
|
||||
const coachProvenanceSchema = z
|
||||
.object({
|
||||
windows: z
|
||||
.array(
|
||||
z.enum([
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"lastYear",
|
||||
"allTime",
|
||||
]),
|
||||
)
|
||||
.describe("Analysis windows the assistant drew on this turn."),
|
||||
metrics: z
|
||||
.array(z.string())
|
||||
.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
|
||||
.record(z.string(), z.number().int())
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-metric sample-count summary; absent on an empty snapshot.",
|
||||
),
|
||||
keyValues: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
unit: z.string().optional(),
|
||||
window: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe(
|
||||
"Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.",
|
||||
),
|
||||
toolCalls: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
present: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.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.",
|
||||
),
|
||||
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.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachProvenance",
|
||||
description:
|
||||
"Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values. No raw timestamps.",
|
||||
});
|
||||
// The provenance envelope and the stream frames come from the Coach's own
|
||||
// wire mirror, which a type test holds equal to the TypeScript contract.
|
||||
|
||||
const coachMessageSchema = z
|
||||
.object({
|
||||
@@ -546,7 +491,7 @@ const coachMessageSchema = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. Null for user turns.",
|
||||
'Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. v1.39.4 — the sentinel `reuse` marks a turn answered from a table already stored in the conversation (a follow-up chip such as "as a chart"), with no model call: render it as a normal assistant turn without a token footer. Null for user turns.',
|
||||
),
|
||||
promptVersion: z
|
||||
.string()
|
||||
@@ -720,7 +665,7 @@ const coachAttachmentsResponseSchema = z
|
||||
const coachChatRequest = coachChatRequestSchema.meta({
|
||||
id: "CoachChatRequest",
|
||||
description:
|
||||
"Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.",
|
||||
"Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). v1.39.4 — `followUp: { messageId, id }` names a follow-up chip the person tapped (send the chip's `label` as `message`); the server resolves the chip from the conversation's latest assistant message, and a chip that is no longer current is treated as a plain message. `clarification: { messageId, choiceId? }` answers a clarifying question, `choiceId` absent when the person typed their own answer. No `userId` field — the owner is narrowed from the session / Bearer.",
|
||||
});
|
||||
|
||||
const anamnesisFactCurrentExistsResponse = {
|
||||
@@ -790,7 +735,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "Send a Coach turn (streaming reply)",
|
||||
description:
|
||||
'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message, reason? }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.',
|
||||
'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event, each a `CoachStreamEvent` dispatched on `type`. Frames, in the order they arrive: `step` (v1.39.4, live progress while the Coach reads the record: `{ type, step }`, upserted by `step.id` from `running` to `done` / `empty` / `failed`; catalog labels, domains, windows and counts only), `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `result` (v1.39.4, a table of the values the turn read: `{ type, result }`, zero or more, sent only to the account that owns the conversation), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `suggestedAction` (v1.22, a confirm-to-apply action card: `{ type, suggestedAction }`; nothing is created until the person confirms it through `POST /api/coach/suggested-actions`), `clarification` (v1.39.4, the choices when the reply is a clarifying question: `{ type, clarification }`; answer with `clarification` on the next request), `followUps` (v1.39.4, up to three chips under the reply: `{ type, followUps }`; send one back with `followUp` on the next request), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), and `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or a single `error` (`{ type, code, message, reason? }`). No frame added after v1.18.9 carries a top-level key an older frame uses, so a client decoding every frame into one flat struct is safe. The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -800,14 +745,10 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames.",
|
||||
"Server-Sent Events stream: each `data: <json>\\n\\n` frame is one `CoachStreamEvent`. Keepalive comment lines (`: ka`) carry no data and are ignored.",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: {
|
||||
type: "string",
|
||||
description:
|
||||
"SSE frames: `data: <json>\\n\\n`. See the operation description for the per-`type` frame shapes.",
|
||||
},
|
||||
schema: coachStreamEventSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -824,6 +765,49 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/chat/{id}/messages/{messageId}/results": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Read the tables one Coach message read",
|
||||
description:
|
||||
"v1.39.4 — the tables of values one assistant message read, decrypted server-side for the account that owns the conversation. One entry per table the message's `metricSource.results` lists, in that order: the full `CoachResultTable`, or `{ ref, withheld }` when it is not served — `module_disabled` when the table's domain is switched off for the record now, `unavailable` when the stored tables cannot be read. A message without tables answers an empty list. Fetch lazily, per message, when a table scrolls into view. Never AI-gated: the tables are stored data and stay readable while the Coach is unavailable. A foreign or unknown conversation or message id maps to 404 (never 403). Auth via cookie or Bearer.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Conversation id.",
|
||||
},
|
||||
{
|
||||
name: "messageId",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Assistant message id within the conversation.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The message's tables, each served whole or withheld.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ results: z.array(coachResultEntrySchema) }),
|
||||
"CoachMessageResultsResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"Conversation or message not found, or not owned by the caller.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/chat/{id}": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
|
||||
Reference in new issue
Block a user