refactor: 精简发给模型的上下文、隐藏接口原始报错,合并重复的删除逻辑
- 发给模型的对话只留最近 20 条,除最后一条 AI 回复外旧回复的代码块换成占位文字, 对话越长越省 token,也不会超出上下文长度 - 学生看到的 AI 报错只显示超时 / 繁忙 / 出错(状态码),完整错误写日志; 客户端自己断开导致的中止不再记错误 - 管理员查询对话必须带 task_id 或 user_id,不再一次查出全站所有对话 - “已评分或获奖不能删”和“删除一轮对话”合并到 services/cascade.ts,补上删除一轮对话的测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
325474bdb7
commit
ffdfbc2513
8 files changed
+122
-35
No files matched your search
+11
-17
@@ -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<Env>()
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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<MessageRow>(`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: "删除成功" })
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -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: "<p>1", prompt: "第一版" })
|
||||
const second = await stu("POST", "/api/submission/", { task_id: task, html: "<p>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")
|
||||
|
||||
@@ -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<p>${i}</p>\n\`\`\`` : `改一下 ${i}`,
|
||||
}))
|
||||
const out = trimHistory(history)
|
||||
expect(out).toHaveLength(20)
|
||||
expect(out[0]!.content).toBe("改一下 10")
|
||||
expect(out.at(-1)!.content).toContain("<p>29</p>")
|
||||
expect(out.at(-3)!.content).toBe("第27版\n```\n(旧版本代码已省略)\n```")
|
||||
expect(out.filter((m) => m.content.includes("<p>"))).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
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user