feat: rolling conversation summary + session continuity

- Backend emits session id in SSE; frontend sets activeChat so follow-ups
  reuse the same session (fixes fragmented history).
- sessions.summary column; messages past RECENT_LIMIT (10) are folded into
  a persisted summary via a non-streaming model call.
- buildMessages sends system(+summary) + last 10 messages, bounding context
  while preserving long-range memory.
- Tests: aiMessages (capping + summary injection).
This commit is contained in:
2026-08-19 10:23:56 -04:00
parent 56d6f355d1
commit 534b51b41b
7 changed files with 175 additions and 26 deletions
+21 -3
View File
@@ -1,6 +1,6 @@
import express from "express";
import { streamChatResponse } from "../services/ai.js";
import { createSession, addMessage, getMessages, getSession, listSessions } from "../services/db.js";
import { RECENT_LIMIT, streamChatResponse, summarizeConversation } from "../services/ai.js";
import { createSession, addMessage, getMessages, getSession, listSessions, setSummary } from "../services/db.js";
import { isOwnedSession, validateChatInput } from "./chatValidation.js";
const router = express.Router();
@@ -33,6 +33,23 @@ router.post("/", async (req, res) => {
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
// Tell the client which session this conversation belongs to, so follow-ups
// reuse the same session and history accumulates.
res.write(`data: ${JSON.stringify({ type: "session", id: currentChatId })}\n\n`);
// Bounded context: fold older messages into a persistent summary, keep the
// last RECENT_LIMIT raw.
let summary = session.summary || "";
if (chatMessages.length > RECENT_LIMIT) {
const overflow = chatMessages.slice(0, chatMessages.length - RECENT_LIMIT);
try {
summary = await summarizeConversation(session.summary, overflow);
await setSummary(currentChatId, summary);
} catch (err) {
console.error("Summarize error:", err.message);
}
}
let assistantText = "";
await streamChatResponse(
chatMessages,
@@ -50,7 +67,8 @@ router.post("/", async (req, res) => {
() => {
res.write(`data: ${JSON.stringify({ type: "error", message: "AI service error" })}\n\n`);
res.end();
}
},
summary
);
res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
+47 -11
View File
@@ -39,21 +39,57 @@ function buildSystemPrompt(grade, subject, chapter) {
return context.join("\n");
}
// Keep the last N messages raw; older ones are folded into a session summary.
export const RECENT_LIMIT = 10;
/**
* Build the messages array for the AI call.
* Older context is passed as a `summary` (folded into the system prompt) and
* only the last RECENT_LIMIT messages are sent, keeping the request bounded.
*/
function buildMessages(chatMessages, grade, subject, chapter) {
const systemMsg = {
role: "system",
content: buildSystemPrompt(grade, subject, chapter),
};
export function buildMessages(chatMessages, grade, subject, chapter, summary) {
const systemPrompt = buildSystemPrompt(grade, subject, chapter);
const summaryText = summary && summary.trim() ? summary.trim() : "";
const systemContent = summaryText
? `${systemPrompt}\n\nEarlier in this conversation:\n${summaryText}`
: systemPrompt;
const userMessages = chatMessages.map((m) => ({
const recent = chatMessages.slice(-RECENT_LIMIT).map((m) => ({
role: m.role === "user" ? "user" : "assistant",
content: m.text,
}));
return [systemMsg, ...userMessages];
return [{ role: "system", content: systemContent }, ...recent];
}
/**
* Fold older messages (plus any existing summary) into a concise running
* summary. Non-streaming. Returns the new summary text.
*/
export async function summarizeConversation(existingSummary, messages) {
const isOpenRouter = (process.env.OPENAI_BASE_URL || "").includes("openrouter.ai");
const body = existingSummary && existingSummary.trim()
? `Prior summary:\n${existingSummary.trim()}\n\nNew messages:\n${messages.map((m) => `${m.role}: ${m.text}`).join("\n")}`
: messages.map((m) => `${m.role}: ${m.text}`).join("\n");
const res = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL || "gpt-4o",
messages: [
{
role: "system",
content:
"You maintain a concise running summary of an ongoing tutoring conversation. " +
"Combine the prior summary (if any) with the new messages into one brief summary (max ~150 words). " +
"Keep the student's grade, subject, chapter, and any goals or open questions. " +
"Do not answer questions — only summarize.",
},
{ role: "user", content: body },
],
max_tokens: 512,
...(isOpenRouter ? { reasoning: { enabled: false } } : {}),
});
return (res.choices?.[0]?.message?.content || "").trim();
}
/**
@@ -229,8 +265,8 @@ async function streamGoogle(messages, onChunk, onError) {
* @param {Function} onError - Called on error
* @returns {Promise<string>} The full response text
*/
export async function streamChatResponse(chatMessages, grade, subject, chapter, onChunk, onError) {
const messages = buildMessages(chatMessages, grade, subject, chapter);
export async function streamChatResponse(chatMessages, grade, subject, chapter, onChunk, onError, summary) {
const messages = buildMessages(chatMessages, grade, subject, chapter, summary);
switch (provider) {
case "anthropic":
@@ -246,8 +282,8 @@ export async function streamChatResponse(chatMessages, grade, subject, chapter,
/**
* Get a non-streaming response (fallback for simple queries).
*/
export async function getChatResponse(chatMessages, grade, subject, chapter) {
const messages = buildMessages(chatMessages, grade, subject, chapter);
export async function getChatResponse(chatMessages, grade, subject, chapter, summary) {
const messages = buildMessages(chatMessages, grade, subject, chapter, summary);
let response;
switch (provider) {
+14
View File
@@ -119,3 +119,17 @@ export async function clearMessages(sessionId, token) {
if (error) throw new Error(`Failed to clear messages: ${error.message}`);
return true;
}
/**
* Persist the rolling conversation summary on a session.
*/
export async function setSummary(sessionId, summary, token) {
const supabase = token ? createUserClient(token) : supabaseAdmin;
const { error } = await supabase
.from('sessions')
.update({ summary, updated_at: new Date().toISOString() })
.eq('id', sessionId);
if (error) throw new Error(`Failed to set summary: ${error.message}`);
return true;
}
+33
View File
@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildMessages, RECENT_LIMIT } from "../src/services/ai.js";
function msg(role, i) {
return { id: String(i), session_id: "s", role, text: `${role}-${i}`, created_at: new Date(i).toISOString() };
}
test("caps message history to the recent window", () => {
const history = Array.from({ length: RECENT_LIMIT + 5 }, (_, i) => msg(i % 2 ? "assistant" : "user", i));
const built = buildMessages(history, "Grade 10", "math", "c", "");
// system + RECENT_LIMIT messages
assert.equal(built.length, 1 + RECENT_LIMIT);
assert.equal(built[0].role, "system");
// the newest message is included
assert.equal(built[built.length - 1].content, "user-" + (RECENT_LIMIT + 4));
// the oldest message is dropped
assert.ok(!built.some((m) => m.content === "user-0"));
});
test("injects the summary into the system prompt when present", () => {
const history = [msg("user", 0), msg("assistant", 1)];
const built = buildMessages(history, "Grade 6", "english", "c5", "Student is in Grade 6 English.");
assert.equal(built[0].role, "system");
assert.ok(built[0].content.includes("Student is in Grade 6 English."));
// still includes the messages
assert.equal(built.length, 3);
});
test("does not inject an empty summary", () => {
const built = buildMessages([msg("user", 0)], "g", "s", "c", "");
assert.ok(!built[0].content.includes("Earlier in this conversation:"));
});