feat(AI 提示): 提示落库并收集学生评价,落进 ai_hint
Deploy / deploy (push) Has been cancelled

AI 时代 OJ 设计的 2a:先把现有提示记下来,为后面的分级和两段式诊断攒对照数据。
提示本身的行为不变,发给模型的 prompt 一字未改。
- 迁移 0017 建 ai_hint:模型、prompt 版本、内容、错误、耗时、学生评价;
  成功和失败都记(失败率是 AI 悄悄变差时最先动的数)。挂在 submission 上 CASCADE
- streamChat 第三个参数改成 { onComplete, onError }:onComplete 的返回值并进
  done 事件,提示 id 由此带回前端;班级学情分析那处调用同步改写,行为不变
- 落库失败只记日志,不影响学生拿到提示
- 新增 POST /ai/hint/:id/feedback:只能评自己的提示(别人的一律 404),可以改票
- 前端提示生成完出「有帮助 / 没帮助」,选中的高亮;后端没存上时不出按钮
- 实跑:本地假 LLM 验证成功 / provider 断开 / 缺 AI_KEY 三条路径的落库,
  评价接口六种请求,浏览器里按钮出现、改票、重复点不发请求

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 06:00:10 -06:00
co-authored by Claude Opus 5
parent a0ef204bd2
commit 47d8f46bdb
9 changed files with 4220 additions and 21 deletions
+97 -15
View File
@@ -1,5 +1,6 @@
import {
aiAnalysisRequestSchema,
aiHintFeedbackRequestSchema,
aiHintRequestSchema,
classAnalysisRequestSchema,
classPkAnalysisRequestSchema,
@@ -913,23 +914,61 @@ aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
const system =
"你是一个风趣的编程老师。请根据学生的详细数据和每周数据给出学习建议,最后写一句鼓励的话。使用 Markdown,不要放在代码块中。"
const prompt = `详细数据: ${JSON.stringify({ ...details, solved: solved.results })}\n每周或每月数据: ${JSON.stringify(duration)}`
return streamChat(system, prompt, async (analysis) => {
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
await db.insert(schema.aiAnalysis).values({
provider: config.aiProvider,
model: config.aiModel,
data: { details, duration, solved: solved.results },
systemPrompt: system,
userPrompt: "学习详情与周期数据",
analysis,
createTime: new Date().toISOString(),
userId: user.id,
isPinned: false,
})
return streamChat(system, prompt, {
onComplete: async (analysis) => {
// 报告归被分析的那个人,不归发起请求的人 —— 教师后台的 pin 和学生侧的
// GET /ai/pinned 都是按 user_id 找报告的,记在教师名下学生就永远看不到
await db.insert(schema.aiAnalysis).values({
provider: config.aiProvider,
model: config.aiModel,
data: { details, duration, solved: solved.results },
systemPrompt: system,
userPrompt: "学习详情与周期数据",
analysis,
createTime: new Date().toISOString(),
userId: user.id,
isPinned: false,
})
},
})
})
/**
* 改了 /ai/hint 的 system 或 prompt 拼法就把这个数加一,落进 ai_hint.prompt_version
* 事后对比「改之前 / 改之后」的评价和做出率才分得开两批数据。
*/
const HINT_PROMPT_VERSION = 1
/**
* 记一条提示(成功或失败)。**失败只打日志、返回 null** —— 留痕是附带的,
* 不能因为它写不进去就让学生看到「AI 提示生成失败」。
*/
async function recordHint(
submissionId: string,
startedAt: number,
content: string,
error: string | null,
) {
try {
const [row] = await db
.insert(schema.aiHint)
.values({
submissionId,
model: config.aiModel,
promptVersion: HINT_PROMPT_VERSION,
content,
error,
durationMs: Math.round(performance.now() - startedAt),
createTime: new Date().toISOString(),
})
.returning({ id: schema.aiHint.id })
return row?.id ?? null
} catch (e) {
console.error("Failed to record AI hint", e)
return null
}
}
aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const parsed = aiHintRequestSchema.safeParse(
await c.req.json().catch(() => null),
@@ -989,7 +1028,50 @@ aiRoutes.post("/ai/hint", requireAuth, async (c) => {
const system =
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${judgeStatusName(row.submission.result)}\n错误:${String(objectValue(row.submission.statisticInfo).err_info ?? "无")}\n代码:${row.submission.code.slice(0, 2000)}`
return streamChat(system, prompt)
const submissionId = row.submission.id
const startedAt = performance.now()
return streamChat(system, prompt, {
onComplete: async (content) => {
const id = await recordHint(submissionId, startedAt, content, null)
// 落库失败就不带 id:前端据此不出评价按钮,提示本身照常显示
return id === null ? undefined : { hintId: id }
},
onError: async (message) => {
await recordHint(submissionId, startedAt, "", message)
},
})
})
aiRoutes.post("/ai/hint/:id/feedback", requireAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = aiHintFeedbackRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!id || !parsed.success)
return failure(c, 400, "invalid-request", "helpful is required")
// 只能评自己的提示:顺着 submission 核对是不是本人。别人的和不存在的一样回 404,
// 不透露那个 id 上有没有东西
const [updated] = await db
.update(schema.aiHint)
.set({
helpful: parsed.data.helpful,
feedbackTime: new Date().toISOString(),
})
.where(
and(
eq(schema.aiHint.id, id),
inArray(
schema.aiHint.submissionId,
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(eq(schema.submission.userId, c.get("user")!.id)),
),
),
)
.returning({ id: schema.aiHint.id })
if (!updated) return failure(c, 404, "hint-not-found", "Hint not found")
return success(c, null)
})
aiRoutes.post("/ai/class-analysis", requireAuth, async (c) => {