mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
refactor(coach): count the assistant messages before the window inside the conversation read
This commit is contained in:
1 parent
4e47670f25
commit
d6ac4acad2
5 files changed
+97
-71
No files matched your search
@@ -169,7 +169,7 @@ describe("POST /api/insights/chat — fence guard", () => {
|
||||
expect(fetchConversationWithMessages).toHaveBeenCalledWith(
|
||||
"u1",
|
||||
"fenced-conv",
|
||||
{ documentScoped: false },
|
||||
{ documentScoped: false, countEarlierAssistant: true },
|
||||
);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -609,20 +609,7 @@ 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).
|
||||
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 } },
|
||||
});
|
||||
}
|
||||
const CONVERSATION_MESSAGE_DETAIL_CAP = 200;
|
||||
|
||||
/**
|
||||
* Fetch one conversation + its messages, decrypting each body on
|
||||
@@ -649,7 +636,17 @@ export async function fetchConversationWithMessages(
|
||||
* the path id so it can only ever load a conversation that actually holds
|
||||
* that document. Never combined with `documentScoped`.
|
||||
*/
|
||||
opts?: { documentScoped?: boolean; attachedDocumentId?: string },
|
||||
opts?: {
|
||||
documentScoped?: boolean;
|
||||
attachedDocumentId?: string;
|
||||
/**
|
||||
* v1.39.4 — also count the assistant messages older than the loaded
|
||||
* window (`earlierAssistantMessages`), so a turn names earlier tables by
|
||||
* their place in the whole conversation. One count, read only when the
|
||||
* window is full.
|
||||
*/
|
||||
countEarlierAssistant?: boolean;
|
||||
},
|
||||
): Promise<CoachConversationDetailDTO | null> {
|
||||
const row = await prisma.coachConversation.findFirst({
|
||||
where: {
|
||||
@@ -714,6 +711,19 @@ export async function fetchConversationWithMessages(
|
||||
}
|
||||
|
||||
const attachments = mapAttachments(row.attachments);
|
||||
const oldestLoaded = orderedMessages[0];
|
||||
const earlierAssistantMessages =
|
||||
opts?.countEarlierAssistant &&
|
||||
oldestLoaded &&
|
||||
row.messages.length >= CONVERSATION_MESSAGE_DETAIL_CAP
|
||||
? await prisma.coachMessage.count({
|
||||
where: {
|
||||
conversationId: row.id,
|
||||
role: "assistant",
|
||||
createdAt: { lt: oldestLoaded.createdAt },
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
title: readConversationTitle(row),
|
||||
@@ -726,6 +736,9 @@ export async function fetchConversationWithMessages(
|
||||
attachments,
|
||||
attachmentCount: attachments.length,
|
||||
documentTitle: attachments[0]?.title ?? null,
|
||||
...(earlierAssistantMessages !== undefined
|
||||
? { earlierAssistantMessages }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
countAssistantMessagesBefore: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
CONVERSATION_MESSAGE_DETAIL_CAP: 200,
|
||||
appendMessage: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
fetchConversationWithMessages: m.fetchConversationWithMessages,
|
||||
countAssistantMessagesBefore: m.countAssistantMessagesBefore,
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachConversation: { findFirst: m.findFirst },
|
||||
coachMessage: { count: m.count },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: vi.fn(),
|
||||
decryptFromBytes: vi.fn(() => "x"),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
|
||||
import { resolveTurnConversation } from "../conversation";
|
||||
|
||||
@@ -38,24 +40,42 @@ const TABLE = {
|
||||
displayed: true,
|
||||
};
|
||||
|
||||
/** `n` messages alternating user / assistant, the last one holding a table. */
|
||||
function window(n: number) {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `m${i}`,
|
||||
role: i % 2 === 0 ? "user" : "assistant",
|
||||
content: "x",
|
||||
createdAt: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
|
||||
metricSource: i === n - 1 ? { results: [TABLE] } : null,
|
||||
providerType: i % 2 === 0 ? null : "openai",
|
||||
}));
|
||||
/**
|
||||
* The newest `n` messages as the detail read returns them (newest first),
|
||||
* alternating user / assistant, the newest assistant one holding a table.
|
||||
*/
|
||||
function rows(n: number) {
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const age = i; // 0 = newest
|
||||
const assistant = age % 2 === 0;
|
||||
return {
|
||||
id: `m${n - age}`,
|
||||
role: assistant ? "assistant" : "user",
|
||||
encryptedContent: new Uint8Array(),
|
||||
createdAt: new Date(Date.UTC(2026, 0, 1, 0, n - age)),
|
||||
metricSourceJson:
|
||||
age === 0
|
||||
? JSON.stringify({ windows: [], metrics: [], results: [TABLE] })
|
||||
: null,
|
||||
providerType: assistant ? "openai" : null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function priorOf(messages: ReturnType<typeof window>) {
|
||||
m.fetchConversationWithMessages.mockResolvedValue({
|
||||
async function priorOf(messages: ReturnType<typeof rows>) {
|
||||
m.findFirst.mockResolvedValue({
|
||||
id: "c1",
|
||||
titleEncrypted: null,
|
||||
title: "t",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
documentScoped: false,
|
||||
summaryEncrypted: null,
|
||||
messages,
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
attachments: [],
|
||||
});
|
||||
const out = await resolveTurnConversation({
|
||||
userId: "u1",
|
||||
@@ -68,25 +88,28 @@ async function priorOf(messages: ReturnType<typeof window>) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
m.fetchConversationWithMessages.mockReset();
|
||||
m.countAssistantMessagesBefore.mockReset();
|
||||
m.findFirst.mockReset();
|
||||
m.count.mockReset();
|
||||
});
|
||||
|
||||
describe("prior table names across the loaded window", () => {
|
||||
it("counts the assistant messages before a full window", async () => {
|
||||
m.countAssistantMessagesBefore.mockResolvedValue(40);
|
||||
const prior = await priorOf(window(200));
|
||||
m.count.mockResolvedValue(40);
|
||||
const prior = await priorOf(rows(200));
|
||||
// 100 assistant messages in the window, 40 before it.
|
||||
expect(prior.map((p) => p.turnIndex)).toEqual([140]);
|
||||
expect(m.countAssistantMessagesBefore).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
new Date(Date.UTC(2026, 0, 1, 0, 0)),
|
||||
);
|
||||
expect(m.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
conversationId: "c1",
|
||||
role: "assistant",
|
||||
createdAt: { lt: new Date(Date.UTC(2026, 0, 1, 0, 1)) },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reads no count while the whole conversation is loaded", async () => {
|
||||
const prior = await priorOf(window(10));
|
||||
const prior = await priorOf(rows(10));
|
||||
expect(prior.map((p) => p.turnIndex)).toEqual([5]);
|
||||
expect(m.countAssistantMessagesBefore).not.toHaveBeenCalled();
|
||||
expect(m.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
CONVERSATION_MESSAGE_DETAIL_CAP,
|
||||
appendMessage,
|
||||
countAssistantMessagesBefore,
|
||||
createConversation,
|
||||
fetchConversationWithMessages,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
@@ -23,24 +21,6 @@ import { latestMessagesOnce } from "@/lib/ai/coach/latest-messages";
|
||||
import { streamRefusal } from "./sse";
|
||||
import type { TurnConversation } from "./types";
|
||||
|
||||
/**
|
||||
* How many assistant messages precede the loaded window. The detail read
|
||||
* loads only the newest messages; counting from the first loaded one gave
|
||||
* an older table a new `m<k>` name every turn once a conversation outgrew
|
||||
* the window, so a name the model had read earlier pointed somewhere else.
|
||||
* Read only when the window is full.
|
||||
*/
|
||||
async function assistantMessagesBefore(existing: {
|
||||
id: string;
|
||||
messages: ReadonlyArray<{ createdAt: string }>;
|
||||
}): Promise<number> {
|
||||
const first = existing.messages[0];
|
||||
if (!first || existing.messages.length < CONVERSATION_MESSAGE_DETAIL_CAP) {
|
||||
return 0;
|
||||
}
|
||||
return countAssistantMessagesBefore(existing.id, new Date(first.createdAt));
|
||||
}
|
||||
|
||||
export async function resolveTurnConversation(args: {
|
||||
userId: string;
|
||||
conversationId: string | undefined;
|
||||
@@ -78,6 +58,7 @@ export async function resolveTurnConversation(args: {
|
||||
// instruction against the coach's write tools. Do not relax.
|
||||
const existing = await fetchConversationWithMessages(userId, conversationId, {
|
||||
documentScoped: false,
|
||||
countEarlierAssistant: true,
|
||||
});
|
||||
if (!existing) {
|
||||
// 404, not 403 — never reveal cross-user / cross-mode existence
|
||||
@@ -162,9 +143,13 @@ export async function resolveTurnConversation(args: {
|
||||
priorToolFigures,
|
||||
priorSummary: existing.summary ?? null,
|
||||
// v1.39.4 — the tables earlier replies hold, named for the context.
|
||||
// Named by their place in the whole conversation: counting from the
|
||||
// first loaded message gave an older table a new `m<k>` name every turn
|
||||
// once the conversation outgrew the window, so a name the model had
|
||||
// read earlier pointed somewhere else.
|
||||
priorResults: collectPriorResults(
|
||||
existing.messages,
|
||||
await assistantMessagesBefore(existing),
|
||||
existing.earlierAssistantMessages ?? 0,
|
||||
),
|
||||
latestMessages: latestMessagesOnce(userId, existing.id),
|
||||
},
|
||||
|
||||
@@ -758,6 +758,11 @@ export interface CoachConversationDetailDTO extends CoachConversationDTO {
|
||||
* `insights.coach.fence_drift` audit). Equal to `attachments.length`.
|
||||
*/
|
||||
attachmentCount: number;
|
||||
/**
|
||||
* v1.39.4 — server-internal: the assistant messages older than the loaded
|
||||
* window, when the turn asked for the count. Never sent to a client.
|
||||
*/
|
||||
earlierAssistantMessages?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user