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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:"));
|
||||
});
|
||||
Reference in New Issue
Block a user