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
+49 -11
View File
@@ -19,7 +19,7 @@
| `frontend/src/components/sidebar/Sidebar.css` | Sidebar layout, hover indicators, chat item delete, upgrade card, user info | | `frontend/src/components/sidebar/Sidebar.css` | Sidebar layout, hover indicators, chat item delete, upgrade card, user info |
| `frontend/src/components/chat-history/ChatHistory.jsx` | Welcome screen with bento suggestions OR message list OR selector flow | | `frontend/src/components/chat-history/ChatHistory.jsx` | Welcome screen with bento suggestions OR message list OR selector flow |
| `frontend/src/components/chat-history/ChatHistory.css` | Welcome header, bento grid, info panel, message list | | `frontend/src/components/chat-history/ChatHistory.css` | Welcome header, bento grid, info panel, message list |
| `frontend/src/components/message/Message.jsx` | Single message bubble (user or assistant), action buttons, typing dots | | `frontend/src/components/message/Message.jsx` | Single message bubble (user or assistant), markdown+math rendering for assistant, action buttons, typing dots |
| `frontend/src/components/message/Message.css` | Bubble styling, avatar, actions, typing animation | | `frontend/src/components/message/Message.css` | Bubble styling, avatar, actions, typing animation |
| `frontend/src/components/chat-input/ChatInput.jsx` | Textarea with auto-expand, mic, send button, attachment | | `frontend/src/components/chat-input/ChatInput.jsx` | Textarea with auto-expand, mic, send button, attachment |
| `frontend/src/components/chat-input/ChatInput.css` | Input bar styling, send button states, disclaimer | | `frontend/src/components/chat-input/ChatInput.css` | Input bar styling, send button states, disclaimer |
@@ -630,6 +630,12 @@ if (XSS_PATTERN.test(message.text)) {
- Added regression tests: `backend/test/chatValidation.test.js` and `frontend/test/appState.test.js`. - Added regression tests: `backend/test/chatValidation.test.js` and `frontend/test/appState.test.js`.
- Backend tests, backend syntax checks, frontend regression test, and Vite production build pass. - Backend tests, backend syntax checks, frontend regression test, and Vite production build pass.
- Chrome MCP verified the sign-in screen renders after fixing a missing `useAuth` import exposed by the browser console. - Chrome MCP verified the sign-in screen renders after fixing a missing `useAuth` import exposed by the browser console.
- Cross-user session leak fixed (token cache keyed by full token — see CVE-2026-011).
- AI connected via OpenRouter; streaming verified in the browser with both user+assistant messages persisted.
- Assistant replies render as markdown + KaTeX math; verified in the headed browser.
- Current user message is included in the AI context (persisted before streaming).
- Session continuity fixed: backend emits `session` id in SSE; follow-ups reuse the same session.
- Rolling conversation summary implemented and persisted (bounded context + long-range memory).
### Database status ### Database status
@@ -639,24 +645,56 @@ if (XSS_PATTERN.test(message.text)) {
--- ---
## Data Storage & Conversation Context Model (2026-08-19)
### Q: Each message is a new row — is that sustainable?
**A (yes):** Each message is one row in `public.messages` (`id`, `session_id`, `role`, `text`, `created_at`), written append-only. This is the standard chat design (Slack/OpenAI-style). It is sustainable because:
- Storage is tiny (average turn is ~KB scale; a million messages ≈ a few GB).
- `idx_messages_session_id` keeps per-session history reads fast even with many rows.
- Append-only is desirable: simple pagination/partial loads, immutable history, easy to add edit/regenerate later.
- One-row-per-session-with-big-text-blob would be worse (can't load partial history, bad indexing).
- DB only needs attention at scale: partitioning by date/user past ~10s of millions of rows, or archiving old sessions.
### The real scaling concern: AI context window (the "memory" issue)
- The DB is fine, but on **every** `/api/chat` call the backend loads the **entire** message history and sends it **all** to the model.
- Token count (and therefore **cost and latency**) grows with conversation length; eventually it exceeds the model's context limit and the call fails.
- **This is the lever worth engineering** — not the database.
### Blocked-on note
Before context work has value, the **frontend session-continuity bug** must be fixed (see What's Next #1): follow-ups in a fresh chat currently create a new session with no history, so long-running context never even accumulates.
---
## What's Next ## What's Next
### 1. Frontend chat-flow design gap ### 1. Frontend session continuity — DONE
- Backend does not return the new `session.id` to the client (SSE only sends `chunk`/`done`/`error`). - Backend now emits a `{ type: "session", id }` SSE event when a conversation is created/loaded; the frontend sets `activeChat` on it, so follow-ups reuse the same session and history accumulates.
- Sidebar refresh (`loadSessions()`) only runs on the successful SSE path, so an AI error leaves the sidebar stale even though the session row is created. - Remaining minor: sidebar refresh (`loadSessions()`) only runs on the successful SSE path an AI error still leaves the sidebar stale even though the session row was created.
- Decide: return `session.id` in SSE + refresh sidebar on error path (Option 1), or only persist sessions after a successful AI response (Option 2).
### 2. Frontend authentication handling ### 1b. Rolling conversation summary (B+D) — implemented 2026-08-19
- Handle protected-route HTTP 401 responses by returning to the sign-in modal. - `sessions.summary` column (migration `20260819150000_add_sessions_summary.sql`).
- Persist selector state per user. - On each `/api/chat`, when a session exceeds `RECENT_LIMIT` (10) messages, older messages are folded into a persisted summary via a non-streaming model call (`summarizeConversation`), stored with `setSummary`.
- The model receives `system(+summary) + last 10 messages` (`buildMessages`), so context stays bounded while long-range context persists.
- Verified end-to-end: fresh chat emits session id; a 12-message session folded its overflow into a stored summary; follow-up answered with bounded context.
- Tests: `backend/test/aiMessages.test.js` (capping + summary injection).
### 2. Frontend rendering — fixed
- Assistant replies now render via `react-markdown` (headings, bold, italics, lists, blockquotes, code, tables, `---`) and math (`$H_2O$`, `$CO_2$`) via KaTeX. User messages stayed plain text. `ReactMarkdown` escapes raw HTML by default, so stored-XSS (CVE-2026-009) stays closed while rendering rich content.
- Protected-route 401s still return to the sign-in modal.
- Selector state persists per user.
### AI Provider — OpenRouter (connected 2026-08-19) ### AI Provider — OpenRouter (connected 2026-08-19)
- Backend uses the OpenAI-compatible path (`AI_PROVIDER=openai`) pointed at OpenRouter. - Backend uses the OpenAI-compatible path (`AI_PROVIDER=openai`) pointed at OpenRouter.
- `OPENAI_BASE_URL=https://openrouter.ai/api/v1` (added to `ai.js` as an optional override). - `OPENAI_BASE_URL=https://openrouter.ai/api/v1` (added to `ai.js` as an optional override).
- Model: `~deepseek/deepseek-v4-flash-latest` (the `~` prefix is REQUIRED — it is OpenRouter's "redirect to latest" alias; the non-tilde `deepseek/deepseek-v4-flash-latest` is an invalid model ID). - Model: `qwen/qwen3.7-flash` (final choice; earlier tried `~deepseek/deepseek-v4-flash-latest` and `upstage/solar-pro4`).
- Verified end-to-end: `/api/chat` streams a real reply and persists both `user` and `assistant` messages (lost messages bug from the no-AI path is gone). - OpenRouter "latest" aliases use a required `~` prefix (e.g. `~deepseek/...`); the non-tilde `-latest` is an invalid ID.
- Note: this is a reasoning model — it streams thinking in `delta.reasoning` before `delta.content`; the app reads `delta.content`, so there is a brief (sub-second) gap while reasoning, then the answer streams. - Reasoning disabled for OpenRouter (`reasoning: { enabled: false }` in `streamOpenai`/`getChatResponse`) so `delta.content` streams immediately instead of sitting empty during the thinking phase (which made the chat look blank).
- Verified end-to-end: `/api/chat` streams a real reply and persists both `user` and `assistant` messages.
- The user message is now persisted **before** streaming, so the model sees the current question (fixes canned-greeting responses) and the user message survives AI failures.
**Env (non-secret):** wire via `backend/.env`, and the placeholders are in `backend/.env.example`. **Env (non-secret):** wire via `backend/.env`, and the placeholders are in `backend/.env.example`.
+21 -3
View File
@@ -1,6 +1,6 @@
import express from "express"; import express from "express";
import { streamChatResponse } from "../services/ai.js"; import { RECENT_LIMIT, streamChatResponse, summarizeConversation } from "../services/ai.js";
import { createSession, addMessage, getMessages, getSession, listSessions } from "../services/db.js"; import { createSession, addMessage, getMessages, getSession, listSessions, setSummary } from "../services/db.js";
import { isOwnedSession, validateChatInput } from "./chatValidation.js"; import { isOwnedSession, validateChatInput } from "./chatValidation.js";
const router = express.Router(); const router = express.Router();
@@ -33,6 +33,23 @@ router.post("/", async (req, res) => {
res.setHeader("Connection", "keep-alive"); res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); 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 = ""; let assistantText = "";
await streamChatResponse( await streamChatResponse(
chatMessages, chatMessages,
@@ -50,7 +67,8 @@ router.post("/", async (req, res) => {
() => { () => {
res.write(`data: ${JSON.stringify({ type: "error", message: "AI service error" })}\n\n`); res.write(`data: ${JSON.stringify({ type: "error", message: "AI service error" })}\n\n`);
res.end(); res.end();
} },
summary
); );
res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`); 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"); 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. * 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) { export function buildMessages(chatMessages, grade, subject, chapter, summary) {
const systemMsg = { const systemPrompt = buildSystemPrompt(grade, subject, chapter);
role: "system", const summaryText = summary && summary.trim() ? summary.trim() : "";
content: buildSystemPrompt(grade, subject, chapter), 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", role: m.role === "user" ? "user" : "assistant",
content: m.text, 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 * @param {Function} onError - Called on error
* @returns {Promise<string>} The full response text * @returns {Promise<string>} The full response text
*/ */
export async function streamChatResponse(chatMessages, grade, subject, chapter, onChunk, onError) { export async function streamChatResponse(chatMessages, grade, subject, chapter, onChunk, onError, summary) {
const messages = buildMessages(chatMessages, grade, subject, chapter); const messages = buildMessages(chatMessages, grade, subject, chapter, summary);
switch (provider) { switch (provider) {
case "anthropic": case "anthropic":
@@ -246,8 +282,8 @@ export async function streamChatResponse(chatMessages, grade, subject, chapter,
/** /**
* Get a non-streaming response (fallback for simple queries). * Get a non-streaming response (fallback for simple queries).
*/ */
export async function getChatResponse(chatMessages, grade, subject, chapter) { export async function getChatResponse(chatMessages, grade, subject, chapter, summary) {
const messages = buildMessages(chatMessages, grade, subject, chapter); const messages = buildMessages(chatMessages, grade, subject, chapter, summary);
let response; let response;
switch (provider) { 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}`); if (error) throw new Error(`Failed to clear messages: ${error.message}`);
return true; 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:"));
});
+5 -1
View File
@@ -347,7 +347,11 @@ function App() {
try { try {
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
if (data.type === "chunk") { if (data.type === "session") {
// Remember which session this conversation belongs to so
// follow-up messages reuse it (history stays together).
setActiveChat(data.id);
} else if (data.type === "chunk") {
assistantText += data.content; assistantText += data.content;
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
@@ -0,0 +1,6 @@
-- Add a rolling conversation summary column to sessions.
-- Older messages get folded into this summary so the AI context stays bounded
-- (model receives summary + last N messages) while long-range context persists.
ALTER TABLE public.sessions
ADD COLUMN IF NOT EXISTS summary text;