padhle - post migration

This commit is contained in:
2026-09-15 04:08:55 -04:00
parent 534b51b41b
commit b2d8109019
509 changed files with 147378 additions and 303 deletions
+191 -98
View File
@@ -1,120 +1,213 @@
import express from "express";
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";
import { isOwnedSession, validateChatInput, validateChapter } from "./chatValidation.js";
import trialStore from "../services/anonTrial.js";
import { sseWrite } from "../services/sse.js";
import { logError } from "../services/logRedact.js";
const router = express.Router();
/**
* Router factory. `streamFn` is injected so integration tests can substitute a
* fake AI streamer (defaults to the real one). Returns a configured router.
*/
export function createChatRouter({ streamFn = streamChatResponse } = {}) {
const router = express.Router();
router.post("/", async (req, res) => {
const { text, chatId, grade, subject, chapter } = req.body;
const userId = req.user.uid;
const validation = validateChatInput({ text, grade, subject });
if (!validation.ok) return res.status(400).json({ error: validation.error });
/**
* Anonymous chat handler — the 5-free-message trial.
*
* Flow (enforced server-side):
* 1. Validate input first (same rules as signed-in).
* 2. Resolve the trial: `chatId` continues an existing in-memory trial;
* anything else (missing/unknown/forged id) starts a fresh trial — the
* "refresh loses the conversation, budget resets" behavior. A forged id
* is never treated as a session id and never touches the database.
* 3. If the trial budget is spent → 403 SIGNIN_REQUIRED before any AI call.
* 4. Otherwise consume one user message, stream the reply, and emit
* `session` (trial id) + `limit` (remaining) SSE events.
*/
async function handleAnonChat(req, res, { text, chatId, grade, subject, chapter }) {
let trial = chatId ? trialStore.getTrial(chatId) : null;
if (!trial) trial = trialStore.createTrial(); // unknown id → fresh trial
try {
let session;
if (chatId) {
session = await getSession(chatId);
if (!isOwnedSession(session, userId)) {
return res.status(404).json({ error: "Session not found" });
}
} else {
session = await createSession(userId, grade, subject, chapter);
if (trialStore.isExhausted(trial.id)) {
return res
.status(403)
.json({ error: "Please sign in to continue", code: "SIGNIN_REQUIRED" });
}
const currentChatId = session.id;
// Persist the user message BEFORE streaming so it is included in the AI
// context (the model must see the current question) and survives even if
// the AI call fails.
await addMessage(currentChatId, "user", text.trim());
const chatMessages = await getMessages(currentChatId);
// Consume one free message (only user messages count).
trialStore.addMessage(trial.id, "user", text.trim());
const remaining = trialStore.countRemaining(trial.id);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
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`);
// Same contract as the signed-in path: the client stores this id as
// activeChat and echoes it back as chatId to continue the thread.
sseWrite(res, { type: "session", id: trial.id });
sseWrite(res, { type: "limit", remaining });
// 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);
const context = trialStore.getContextMessages(trial.id).map((m) => ({ role: m.role, text: m.text }));
let assistantText = "";
await streamFn(
context,
grade,
subject,
chapter,
(chunk) => {
assistantText += chunk;
sseWrite(res, { type: "chunk", content: chunk });
},
() => {
sseWrite(res, { type: "error", message: "AI service error" });
res.end();
},
undefined // anonymous trials have no rolling summary
);
if (!res.writableEnded) {
if (assistantText) trialStore.addMessage(trial.id, "assistant", assistantText);
sseWrite(res, { type: "done" });
res.end();
}
}
router.post("/", async (req, res) => {
const { text, chatId, grade, subject, chapter } = req.body;
const validation = validateChatInput({ text, grade, subject });
if (!validation.ok) return res.status(400).json({ error: validation.error });
const chapterCheck = validateChapter(chapter);
if (!chapterCheck.ok) return res.status(400).json({ error: chapterCheck.error });
// Anonymous (no verified JWT) → free-trial path.
if (!req.user) {
try {
summary = await summarizeConversation(session.summary, overflow);
await setSummary(currentChatId, summary);
return await handleAnonChat(req, res, { text, chatId, grade, subject, chapter });
} catch (err) {
console.error("Summarize error:", err.message);
logError("Anonymous chat error", err);
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
else {
sseWrite(res, { type: "error", message: "Internal server error" });
res.end();
}
return;
}
}
let assistantText = "";
await streamChatResponse(
chatMessages,
grade || session.grade,
subject || session.subject,
chapter || session.chapter,
(chunk) => {
assistantText += chunk;
const safe = JSON.stringify({ type: "chunk", content: chunk })
.replace(/\n/g, "\\n")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
res.write(`data: ${safe}\n\n`);
},
() => {
res.write(`data: ${JSON.stringify({ type: "error", message: "AI service error" })}\n\n`);
const userId = req.user.uid;
try {
let session;
if (chatId) {
session = await getSession(chatId, req.token);
if (!isOwnedSession(session, userId)) {
return res.status(404).json({ error: "Session not found" });
}
} else {
session = await createSession(userId, grade, subject, chapter, req.token);
}
const currentChatId = session.id;
// Persist the user message BEFORE streaming so it is included in the AI
// context (the model must see the current question) and survives even if
// the AI call fails.
await addMessage(currentChatId, "user", text.trim(), req.token);
const chatMessages = await getMessages(currentChatId, req.token);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
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.
sseWrite(res, { type: "session", id: currentChatId });
// 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, req.token);
} catch (err) {
logError("Summarize error", err);
}
}
let assistantText = "";
await streamFn(
chatMessages,
grade || session.grade,
subject || session.subject,
chapter || session.chapter,
(chunk) => {
assistantText += chunk;
sseWrite(res, { type: "chunk", content: chunk });
},
() => {
sseWrite(res, { type: "error", message: "AI service error" });
res.end();
},
summary
);
if (!res.writableEnded) {
sseWrite(res, { type: "done" });
await addMessage(currentChatId, "assistant", assistantText, req.token);
}
} catch (err) {
logError("Chat error", err);
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
else {
sseWrite(res, { type: "error", message: "Internal server error" });
res.end();
},
summary
);
res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
await addMessage(currentChatId, "assistant", assistantText);
} catch (err) {
console.error("Chat error:", err.message);
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
else res.write(`data: ${JSON.stringify({ type: "error", message: "Internal server error" })}\n\n`);
} finally {
res.end();
}
});
// Static route must precede /:chatId.
router.get("/sessions", async (req, res) => {
try {
res.json(await listSessions(req.user.uid));
} catch (err) {
console.error("List sessions error:", err.message);
res.status(500).json({ error: "Failed to load sessions" });
}
});
router.get("/:chatId", async (req, res) => {
try {
const { chatId } = req.params;
const session = await getSession(chatId);
if (!isOwnedSession(session, req.user.uid)) {
return res.status(404).json({ error: "Session not found" });
}
} finally {
res.end();
}
const msgHistory = await getMessages(chatId);
res.json({
session: {
id: session.id,
grade: session.grade,
subject: session.subject,
chapter: session.chapter,
createdAt: session.created_at,
updatedAt: session.updated_at,
},
messages: msgHistory,
});
} catch (err) {
console.error("Get chat error:", err.message);
res.status(500).json({ error: "Failed to load session" });
}
});
});
export default router;
// Static route must precede /:chatId.
router.get("/sessions", async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
try {
res.json(await listSessions(req.user.uid, req.token));
} catch (err) {
logError("List sessions error", err);
res.status(500).json({ error: "Failed to load sessions" });
}
});
router.get("/:chatId", async (req, res) => {
if (!req.user) return res.status(401).json({ error: "Authentication required" });
try {
const { chatId } = req.params;
const session = await getSession(chatId, req.token);
if (!isOwnedSession(session, req.user.uid)) {
return res.status(404).json({ error: "Session not found" });
}
const msgHistory = await getMessages(chatId, req.token);
res.json({
session: {
id: session.id,
grade: session.grade,
subject: session.subject,
chapter: session.chapter,
createdAt: session.created_at,
updatedAt: session.updated_at,
},
messages: msgHistory,
});
} catch (err) {
logError("Get chat error", err);
res.status(500).json({ error: "Failed to load session" });
}
});
return router;
}
export default createChatRouter();