refactor(coach): keep the table storage budget in the pure refs module

The backup validation now reads the per-message table ceiling from
results/refs, which imports nothing but types. Reading it from
results/project pulled the tool executor and the message store into the
snapshot builder's import graph, and the free-text sanitiser guard then
walked files that never build a prompt.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:15:48 +02:00
1 parent 874d02294e
commit eeda21c7a1
7 files changed
+59 -38

No files matched your search

+15 -2
View File
@@ -23,7 +23,7 @@ import {
coachStepSchema,
} from "./stream-events";
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
import { RESULTS_MAX_BYTES, fitResultsToStorage } from "./results/project";
import { RESULTS_MAX_BYTES, fitResultsToStorage } from "./results/refs";
import {
isCheckupIntervalId,
isSuggestedActionType,
@@ -609,7 +609,20 @@ export async function recordProactiveNudge(
// newest messages up to this cap cover the rendered window without the
// unbounded per-open AES-decrypt cost. The response shape is unchanged —
// the messages array still arrives oldest->newest (see the reverse below).
const CONVERSATION_MESSAGE_DETAIL_CAP = 200;
export const CONVERSATION_MESSAGE_DETAIL_CAP = 200;
/**
* The assistant messages of a conversation written before `before`. The
* caller has already read the conversation owner-narrowed; this only counts.
*/
export function countAssistantMessagesBefore(
conversationId: string,
before: Date,
): Promise<number> {
return prisma.coachMessage.count({
where: { conversationId, role: "assistant", createdAt: { lt: before } },
});
}
/**
* Fetch one conversation + its messages, decrypting each body on
@@ -287,7 +287,7 @@ describe("deriveChartSpec: time series", () => {
});
});
it("leaves the reading count off the chart", () => {
it("leaves the reading count off the chart", () => {
const spec = deriveChartSpec(daySeries("pulse", [60, 62], "bpm"));
expect(spec && "series" in spec ? spec.series : []).not.toContain(
"readings",
-28
View File
@@ -22,34 +22,6 @@ import { MAX_RESULTS_PER_TURN, isTurnResultRef } from "./refs";
/** At most this many rows reach a table; a year of days fits. */
export const RESULT_TABLE_MAX_ROWS = 400;
/**
* The at-rest ceiling for one message's tables, as JSON before encryption.
* Tables arrive trimmed to 400 rows; a message whose tables still exceed
* this keeps the leading tables that fit.
*/
export const RESULTS_MAX_BYTES = 128 * 1024;
/**
* The tables a message keeps: the leading ones whose JSON fits
* `RESULTS_MAX_BYTES`, whole tables dropped from the end. Applied once,
* before the turn streams them, so the tables the person sees live are the
* tables a reload reads back, and the metadata never names a table the
* ciphertext does not hold.
*/
export function fitResultsToStorage(
results: readonly CoachResultTable[],
): CoachResultTable[] {
const kept = results.slice(0, MAX_RESULTS_PER_TURN);
while (
kept.length > 0 &&
new TextEncoder().encode(JSON.stringify(kept)).byteLength >
RESULTS_MAX_BYTES
) {
kept.pop();
}
return kept;
}
/** One tool call of this turn, as the loop settled it. */
export interface SettledToolCall {
name: string;
+40 -4
View File
@@ -7,7 +7,7 @@
* in the tool result it cites from.
* - `m<k>.r<n>` — table `r<n>` of an earlier assistant message of the same
* conversation, `k` being that message's 1-based position among the
* assistant messages the turn loaded. The names are drawn once per turn
* conversation's assistant messages (stable however many are loaded). The names are drawn once per turn
* from the conversation the turn already read (owner-narrowed), and
* `show_result` resolves only against that same list, so a name can never
* reach a table of another conversation or another account.
@@ -17,7 +17,7 @@
*
* Pure: no database, no provider. The reply guards import this module.
*/
import type { CoachResultMeta } from "@/lib/ai/coach/types";
import type { CoachResultMeta, CoachResultTable } from "@/lib/ai/coach/types";
/** At most this many tables per message (`r1`..`r6`). */
export const MAX_RESULTS_PER_TURN = 6;
@@ -52,7 +52,7 @@ export function createResultRefAllocator(): ResultRefAllocator {
/** One earlier assistant message that holds tables. */
export interface PriorResultTurn {
messageId: string;
/** 1-based position among the conversation's loaded assistant messages. */
/** 1-based position among the conversation's assistant messages. */
turnIndex: number;
results: CoachResultMeta[];
}
@@ -70,9 +70,15 @@ interface LoadedMessage {
*/
export function collectPriorResults(
messages: ReadonlyArray<LoadedMessage>,
/**
* Assistant messages written before the first loaded one, so an index is
* the message's position in the whole conversation, not in the window the
* turn happened to load.
*/
earlierAssistantMessages = 0,
): PriorResultTurn[] {
const out: PriorResultTurn[] = [];
let turnIndex = 0;
let turnIndex = earlierAssistantMessages;
for (const message of messages) {
if (message.role !== "assistant") continue;
turnIndex += 1;
@@ -166,3 +172,33 @@ export function stripResultRefs(prose: string): {
referenced,
};
}
// ── What a message keeps ──────────────────────────────────────────────────
/**
* The at-rest ceiling for one message's tables, as JSON before encryption.
* Tables arrive trimmed to 400 rows; a message whose tables still exceed
* this keeps the leading tables that fit.
*/
export const RESULTS_MAX_BYTES = 128 * 1024;
/**
* The tables a message keeps: the leading ones whose JSON fits
* `RESULTS_MAX_BYTES`, whole tables dropped from the end. Applied once,
* before the turn streams them, so the tables the person sees live are the
* tables a reload reads back, and the metadata never names a table the
* ciphertext does not hold.
*/
export function fitResultsToStorage(
results: readonly CoachResultTable[],
): CoachResultTable[] {
const kept = results.slice(0, MAX_RESULTS_PER_TURN);
while (
kept.length > 0 &&
new TextEncoder().encode(JSON.stringify(kept)).byteLength >
RESULTS_MAX_BYTES
) {
kept.pop();
}
return kept;
}
@@ -21,7 +21,7 @@ import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
import { emitReply } from "../sse";
import { assembleTurnDialog, buildTurnProvenance } from "../provenance";
import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/project";
import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs";
import type { ModelOutcome } from "../model";
import type { GuardedReply } from "../reply-guards";
+1 -1
View File
@@ -28,7 +28,7 @@ import {
import { numberFollowUps } from "@/lib/ai/coach/follow-ups/catalog";
import { buildContinueFollowUp } from "@/lib/ai/coach/follow-ups/continue";
import type { CoachPrefs } from "@/lib/validations/coach-prefs";
import { fitResultsToStorage } from "@/lib/ai/coach/results/project";
import { fitResultsToStorage } from "@/lib/ai/coach/results/refs";
import type { ModelOutcome } from "./model";
import type { GuardedReply } from "./reply-guards";
+1 -1
View File
@@ -20,7 +20,7 @@
* a useful summary for them.
*/
import { z } from "zod/v4";
import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/project";
import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs";
import {
AllergyCategory,
AllergySeverity,