diff --git a/CLAUDE.md b/CLAUDE.md index 914b72c..f3f197e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1 - `src/db.ts` — schema (`CREATE TABLE IF NOT EXISTS`, same DDL as Django), `all/get/run/tx` helpers using `$name` params, time (`now()`, `iso()`) and UUID (`uuidHex()`, `uuidOut()`) conversions - `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403) - `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt` - - `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes, done by hand), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit) + - `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit) - `src/ws.ts` — `/ws/prompt//?token=` (code generation chat) and `/ws/guidance//?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted - `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts` - Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`) diff --git a/server/src/routes/prompt.ts b/server/src/routes/prompt.ts index 9d67ec0..c67501f 100644 --- a/server/src/routes/prompt.ts +++ b/server/src/routes/prompt.ts @@ -1,15 +1,15 @@ import { Hono } from "hono" import { z } from "zod" -import { all, get, run, tx, iso, uuidOut } from "../db" +import { all, get, tx, iso, uuidOut } from "../db" import { Role, isStaff, requireUser } from "../auth" -import { HttpError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http" -import { deleteSubmissions } from "../services/cascade" +import { HttpError, ValidationError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http" +import { deleteRound, hasRatingsOrAwards } from "../services/cascade" import { classifyConversationMessages, classifyMessagesBatch, inBackground, } from "../services/classifier" -import { getPrecedingUserMessage, type MessageRow } from "../services/prompt" +import type { MessageRow } from "../services/prompt" const router = new Hono() @@ -30,6 +30,10 @@ router.get("/conversations", (c) => { where.push("c.task_id = $task_id") params.task_id = q.task_id } + // 不带条件就是全站所有对话(每个还要数一遍消息),会卡住整个服务 + if (!where.length) { + throw new ValidationError([{ loc: ["query", "task_id"], msg: "需要 task_id 或 user_id" }]) + } const rows = all( `SELECT c.id, c.user_id, u.username, c.task_id, t.title AS task_title, c.is_active, c.created, (SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) AS message_count @@ -177,20 +181,10 @@ router.delete("/messages/:message_id/pair", (c) => { if (asst.owner_id !== user.id && user.role !== Role.SUPER) { throw new HttpError(403, "只能删除自己的消息") } - if (asst.submission_id && user.role !== Role.SUPER) { - const used = get( - `SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id) - OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`, - { id: asst.submission_id }, - ) - if (used) throw new HttpError(400, "该消息关联的提交已被评分或获奖,无法删除") + if (asst.submission_id && user.role !== Role.SUPER && hasRatingsOrAwards(asst.submission_id)) { + throw new HttpError(400, "该消息关联的提交已被评分或获奖,无法删除") } - const userMsg = getPrecedingUserMessage(asst) - tx(() => { - if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id }) - run(`DELETE FROM prompt_message WHERE id = $id`, { id: asst.id }) - if (asst.submission_id) deleteSubmissions("id = $id", { id: asst.submission_id }) - }) + tx(() => deleteRound(asst)) return c.json({ deleted: true, submission_deleted: !!asst.submission_id }) }) diff --git a/server/src/routes/submission.ts b/server/src/routes/submission.ts index 38d9187..2beb0d1 100644 --- a/server/src/routes/submission.ts +++ b/server/src/routes/submission.ts @@ -16,7 +16,7 @@ import { type Env, uuidParam, } from "../http" -import { deleteSubmissions } from "../services/cascade" +import { deleteRound, deleteSubmissions, hasRatingsOrAwards } from "../services/cascade" import { updateSubmissionScore } from "../services/score" import { classifyMessage, inBackground } from "../services/classifier" import { buildGradebook, gradebookCsvRows, toCsv } from "../services/gradebook" @@ -82,13 +82,6 @@ function submissionOut(s: any) { } } -const hasRatingsOrAwards = (submissionId: string) => - !!get( - `SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id) - OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`, - { id: submissionId }, - ) - // ── 创建提交 ────────────────────────────────────────────────────────────────── router.post("/", async (c) => { @@ -508,11 +501,10 @@ router.delete("/:id", (c) => { throw new HttpError(400, "该提交已被评分或获奖,无法删除") } tx(() => { - // 关联的助手消息会随提交一起删除,它前面那条用户消息也一起删掉 + // 有关联的 AI 回复时,回复和它前面的提问一起删掉 const asst = get(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id }) - const userMsg = asst ? getPrecedingUserMessage(asst) : null - deleteSubmissions("id = $id", { id }) - if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id }) + if (asst) deleteRound(asst) + else deleteSubmissions("id = $id", { id }) }) return c.json({ message: "删除成功" }) }) diff --git a/server/src/services/cascade.ts b/server/src/services/cascade.ts index 09f7314..4ec86e6 100644 --- a/server/src/services/cascade.ts +++ b/server/src/services/cascade.ts @@ -1,4 +1,5 @@ -import { all, run } from "../db" +import { all, get, run } from "../db" +import { getPrecedingUserMessage, type MessageRow } from "./prompt" // Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。 // 调用方负责包在事务里。 @@ -15,6 +16,22 @@ export function deleteSubmissions(where: string, params: Record run(`DELETE FROM submission_submission WHERE ${where}`, params) } +// 被评过分或获过奖的提交只有超管能删 +export const hasRatingsOrAwards = (submissionId: string) => + !!get( + `SELECT 1 WHERE EXISTS (SELECT 1 FROM submission_rating WHERE submission_id = $id) + OR EXISTS (SELECT 1 FROM submission_submissionaward WHERE submission_id = $id)`, + { id: submissionId }, + ) + +// 删除一轮对话:AI 回复、它前面那条用户提问,以及回复关联的提交 +export function deleteRound(asst: MessageRow) { + const userMsg = getPrecedingUserMessage(asst) + if (userMsg) run(`DELETE FROM prompt_message WHERE id = $id`, { id: userMsg.id }) + run(`DELETE FROM prompt_message WHERE id = $id`, { id: asst.id }) + if (asst.submission_id) deleteSubmissions("id = $id", { id: asst.submission_id }) +} + export function deleteTask(taskId: number) { const p = { task_id: taskId } deleteSubmissions("task_id = $task_id", p) diff --git a/server/src/services/llm.ts b/server/src/services/llm.ts index cc137f1..b9980f1 100644 --- a/server/src/services/llm.ts +++ b/server/src/services/llm.ts @@ -158,9 +158,36 @@ async function* streamCompletion( } } +// 发给模型的对话上下文:只留最近 20 条;除最后一条 AI 回复外,旧回复里的代码块换成占位文字。 +// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度 +const MAX_HISTORY = 20 +const CODE_BLOCK = /```[\s\S]*?```/g + +export function trimHistory(history: ChatMessage[]): ChatMessage[] { + const recent = history.slice(-MAX_HISTORY) + const last = recent.findLastIndex((m) => m.role === "assistant") + return recent.map((m, i) => + m.role === "assistant" && i !== last + ? { ...m, content: m.content.replace(CODE_BLOCK, "```\n(旧版本代码已省略)\n```") } + : m, + ) +} + +// 给学生看的错误:不把模型接口的原始返回(可能带接口细节)原样发出去,完整信息写日志 +export function userFacingError(e: unknown) { + console.error("LLM request failed:", e) + const message = e instanceof Error ? e.message : String(e) + if (message === "模型响应超时" || (e instanceof Error && e.name === "TimeoutError")) { + return "AI 响应超时,请重试" + } + const status = /^Error code: (\d+)/.exec(message)?.[1] + if (status === "429") return "AI 服务繁忙,请稍后重试" + return status ? `AI 服务出错(${status}),请稍后重试` : "AI 服务出错,请稍后重试" +} + export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) { return streamCompletion( - [{ role: "system", content: SYSTEM_PROMPT }, ...history], + [{ role: "system", content: SYSTEM_PROMPT }, ...trimHistory(history)], chatModel(model), signal, ) diff --git a/server/src/ws.ts b/server/src/ws.ts index 35a1a12..90f3dde 100644 --- a/server/src/ws.ts +++ b/server/src/ws.ts @@ -7,6 +7,7 @@ import { parseGuidanceResponse, streamChat, streamGuidance, + userFacingError, type ChatMessage, } from "./services/llm" @@ -122,7 +123,8 @@ async function handlePrompt(ws: WS, data: any) { try { full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal)) } catch (e) { - send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` }) + // 客户端自己断开导致的中止不算出错 + if (!ws.data.closed) send(ws, { type: "error", content: userFacingError(e) }) return } if (ws.data.closed) return @@ -161,7 +163,8 @@ async function handleGuidance(ws: WS, data: any) { } catch (e) { // 这条提问没有回复,留在上下文里会出现连续两条用户消息 ws.data.guidanceHistory.pop() - send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` }) + // 客户端自己断开导致的中止不算出错 + if (!ws.data.closed) send(ws, { type: "error", content: userFacingError(e) }) return } if (ws.data.closed) return diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index 2f42480..b286925 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -226,6 +226,25 @@ describe("提交与删除", () => { expect(all(`SELECT * FROM prompt_message`)).toEqual([]) }) + test("删除一轮对话:连带提问和关联提交;被评过分的不能删", async () => { + makeUser("stu") + makeUser("other") + const task = makeTask("T") + const stu = await client("stu") + const first = await stu("POST", "/api/submission/", { task_id: task, html: "

1", prompt: "第一版" }) + const second = await stu("POST", "/api/submission/", { task_id: task, html: "

2", prompt: "第二版" }) + const asstOf = (sub: string) => + get(`SELECT id FROM prompt_message WHERE submission_id = $id`, { id: sub.replaceAll("-", "") })!.id + + await (await client("other"))("PUT", `/api/submission/${second.json.id}/score`, { score: 4 }) + expect((await stu("DELETE", `/api/prompt/messages/${asstOf(second.json.id)}/pair`)).status).toBe(400) + + const res = await stu("DELETE", `/api/prompt/messages/${asstOf(first.json.id)}/pair`) + expect(res.json).toEqual({ deleted: true, submission_deleted: true }) + expect(all(`SELECT content FROM prompt_message WHERE role = 'user'`)).toEqual([{ content: "第二版" }]) + expect(all(`SELECT COUNT(*) AS n FROM submission_submission`)).toEqual([{ n: 1 }]) + }) + test("被评分的提交只有超管能删", async () => { const author = makeUser("author") makeUser("other") diff --git a/server/tests/unit.test.ts b/server/tests/unit.test.ts index 3311c39..c1d3203 100644 --- a/server/tests/unit.test.ts +++ b/server/tests/unit.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test" import { pyRound, pySum } from "../src/services/pyround" -import { chatModel, extractCode, parseGuidanceResponse, streamChat } from "../src/services/llm" +import { + chatModel, + extractCode, + parseGuidanceResponse, + streamChat, + trimHistory, + userFacingError, +} from "../src/services/llm" import { config } from "../src/config" import { checkPassword, makePassword } from "../src/auth" import { formatMicros, iso, uuidHex, uuidOut } from "../src/db" @@ -114,3 +121,31 @@ describe("streamChat", () => { expect(chatModel("")).toBe(config.llmModel) }) }) + +test("trimHistory 只留最近 20 条,旧回复的代码换成占位", () => { + const history = Array.from({ length: 30 }, (_, i) => ({ + role: (i % 2 ? "assistant" : "user") as "user" | "assistant", + content: i % 2 ? `第${i}版\n\`\`\`html\n

${i}

\n\`\`\`` : `改一下 ${i}`, + })) + const out = trimHistory(history) + expect(out).toHaveLength(20) + expect(out[0]!.content).toBe("改一下 10") + expect(out.at(-1)!.content).toContain("

29

") + expect(out.at(-3)!.content).toBe("第27版\n```\n(旧版本代码已省略)\n```") + expect(out.filter((m) => m.content.includes("

"))).toHaveLength(1) +}) + +test("userFacingError 不透出接口原始返回", () => { + const quiet = console.error + console.error = () => {} + try { + expect(userFacingError(new Error('Error code: 401 - {"error":"invalid api key sk-xxx"}'))).toBe( + "AI 服务出错(401),请稍后重试", + ) + expect(userFacingError(new Error("Error code: 429 - busy"))).toBe("AI 服务繁忙,请稍后重试") + expect(userFacingError(new Error("模型响应超时"))).toBe("AI 响应超时,请重试") + expect(userFacingError(new Error("fetch failed: ECONNREFUSED 10.0.0.1"))).toBe("AI 服务出错,请稍后重试") + } finally { + console.error = quiet + } +})