mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(coach): inject the rolling summary + enqueue memory refresh
fetchConversationWithMessages now returns the decrypted rolling summary (fail-closed → null), and buildHistoryWindow substitutes it for the dead placeholder so a long conversation keeps memory of its elided turns. Once a conversation grows past the turn cap the chat turn fires a fire-and-forget coach-memory-refresh so the next long turn is fresh, without adding a provider round-trip to the interactive reply. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7e29595a71
commit
9a0ea1be56
3 files changed
+55
-15
No files matched your search
@@ -55,6 +55,7 @@ import {
|
||||
fetchConversationWithMessages,
|
||||
listConversations,
|
||||
} from "@/lib/ai/coach/persistence";
|
||||
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
|
||||
import {
|
||||
buildDateKey,
|
||||
enforceBudget,
|
||||
@@ -109,23 +110,26 @@ interface CoachTurn {
|
||||
* `{ role, content }` chat shape the provider clients expect.
|
||||
*
|
||||
* Also enforces the 20-turn cap: when the conversation history exceeds
|
||||
* `TURN_CAP`, the older half is folded into a single synthetic
|
||||
* "[summary]" user message so the prompt budget stays bounded. This is
|
||||
* best-effort — we don't pay for a separate provider call to summarise;
|
||||
* we just keep the last `RECENT_HISTORY` turns verbatim and prepend a
|
||||
* placeholder line that names the elided count.
|
||||
* `TURN_CAP`, the older half is folded out of the verbatim window. v1.11.1 —
|
||||
* if a rolling summary of those elided turns is on file
|
||||
* (`CoachConversation.summaryEncrypted`, refreshed off-budget by the
|
||||
* coach-memory-refresh worker) it is prepended so the Coach keeps memory of
|
||||
* the older conversation; otherwise we fall back to a placeholder that just
|
||||
* names the elided count (the pre-v1.11.1 behaviour). The summary is read
|
||||
* stale-while-revalidate — the current turn uses whatever is on disk, the
|
||||
* enqueued refresh makes the next long turn fresh.
|
||||
*/
|
||||
function buildHistoryWindow(turns: CoachTurn[]): CoachTurn[] {
|
||||
function buildHistoryWindow(
|
||||
turns: CoachTurn[],
|
||||
summary: string | null,
|
||||
): CoachTurn[] {
|
||||
if (turns.length <= TURN_CAP) return turns;
|
||||
const elided = turns.length - RECENT_HISTORY;
|
||||
const recent = turns.slice(turns.length - RECENT_HISTORY);
|
||||
return [
|
||||
{
|
||||
role: "user",
|
||||
content: `[summary placeholder — ${elided} earlier turns elided to stay within the conversation budget]`,
|
||||
},
|
||||
...recent,
|
||||
];
|
||||
const memo = summary
|
||||
? `[earlier conversation summary] ${summary}`
|
||||
: `[summary placeholder — ${elided} earlier turns elided to stay within the conversation budget]`;
|
||||
return [{ role: "user", content: memo }, ...recent];
|
||||
}
|
||||
|
||||
async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
@@ -197,6 +201,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// ── Conversation resolution ──────────────────────────────────
|
||||
let workingConversationId: string;
|
||||
let priorTurns: CoachTurn[] = [];
|
||||
// v1.11.1 — rolling summary of the elided older turns, read stale-while-
|
||||
// revalidate; null for a fresh conversation or when none is on file.
|
||||
let priorSummary: string | null = null;
|
||||
|
||||
if (conversationId) {
|
||||
const existing = await fetchConversationWithMessages(
|
||||
@@ -208,6 +215,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
throw new HttpError(404, "coach.conversation.notFound");
|
||||
}
|
||||
workingConversationId = existing.id;
|
||||
priorSummary = existing.summary ?? null;
|
||||
priorTurns = existing.messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
@@ -286,10 +294,24 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
: scope;
|
||||
const snapshot = await buildCoachSnapshot(userId, effectiveScope);
|
||||
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs);
|
||||
const window = buildHistoryWindow([
|
||||
const allTurns: CoachTurn[] = [
|
||||
...priorTurns,
|
||||
{ role: "user", content: message },
|
||||
]);
|
||||
];
|
||||
const window = buildHistoryWindow(allTurns, priorSummary);
|
||||
// v1.11.1 — once a conversation grows past the history cap, refresh the
|
||||
// rolling summary + extract durable facts off the request path. Fire-and-
|
||||
// forget: this turn uses whatever summary is already on disk; the refresh
|
||||
// makes the next long turn fresh. No-ops without an embedded worker.
|
||||
if (allTurns.length > TURN_CAP) {
|
||||
void enqueueCoachMemoryRefresh({
|
||||
conversationId: workingConversationId,
|
||||
userId,
|
||||
// Coach memory prose is composed in de/en only (the snapshot's
|
||||
// coachLocale); collapse the wider UI locale union here.
|
||||
locale: locale === "en" ? "en" : "de",
|
||||
});
|
||||
}
|
||||
const transcript = window
|
||||
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
|
||||
.join("\n\n");
|
||||
|
||||
@@ -218,6 +218,18 @@ export async function fetchConversationWithMessages(
|
||||
promptVersion: m.promptVersion,
|
||||
}));
|
||||
|
||||
// v1.11.1 — decrypt the rolling conversation summary (fail-closed: an
|
||||
// undecryptable row is treated as absent so the chat turn never throws and
|
||||
// simply falls back to the placeholder).
|
||||
let summary: string | null = null;
|
||||
if (row.summaryEncrypted && row.summaryEncrypted.byteLength > 0) {
|
||||
try {
|
||||
summary = decryptFromBytes(row.summaryEncrypted);
|
||||
} catch {
|
||||
summary = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
@@ -225,6 +237,7 @@ export async function fetchConversationWithMessages(
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
messageCount: messages.length,
|
||||
messages,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -299,6 +299,11 @@ export interface CoachMessageDTO {
|
||||
|
||||
export interface CoachConversationDetailDTO extends CoachConversationDTO {
|
||||
messages: CoachMessageDTO[];
|
||||
/**
|
||||
* v1.11.1 — decrypted rolling summary of the turns elided past the history
|
||||
* window, or null when none is on file / it could not be decrypted.
|
||||
*/
|
||||
summary?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user