import type { FlowchartUpdate } from "@oj2/contract" import { eq } from "drizzle-orm" import { config } from "../config" import { db, schema } from "../db" import { publishFlowchartUpdate } from "../events" import { completeChat } from "../services/ai" import type { FlowchartJobData } from "./job" function evaluationPrompt(problem: typeof schema.problem.$inferSelect) { return `你是专业的编程教学助手,负责评估学生的 Mermaid 流程图。 评分:逻辑正确性40分、完整性30分、规范性20分、清晰度10分。 不要评价系统生成的节点ID。feedback不超过100字;suggestions最多3条且只针对真实问题。 返回纯 JSON:{"score":85,"grade":"A","feedback":"...","suggestions":"...","criteria_details":{}}。 等级:S=90-100,A=80-89,B=70-79,C=0-69。 题目:${problem.title}\n${problem.description.slice(0, 2000)}` } /** * 等级一律由分数推出来,不采信模型自报的 grade。 * 提示词里写死了这四档,但模型偶尔会给出 88 分配 S 级这种自相矛盾的结果, * 甚至直接吐「优秀」;脏值会一路串到等级分布图和「A/S 才展示流程图」的判断里。 */ function gradeForScore(score: number) { if (score >= 90) return "S" if (score >= 80) return "A" if (score >= 70) return "B" return "C" } function parseEvaluation(value: string) { const block = value.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] const json = block ?? value.match(/\{[\s\S]*\}/)?.[0] if (!json) throw new Error("AI response did not contain JSON") const data = JSON.parse(json) as Record if (typeof data.score !== "number" || Number.isNaN(data.score)) throw new Error("AI response is missing score") const score = Math.max(0, Math.min(100, data.score)) return { score, grade: gradeForScore(score), feedback: typeof data.feedback === "string" ? data.feedback : "", suggestions: typeof data.suggestions === "string" ? data.suggestions : "", criteria: data.criteria_details && typeof data.criteria_details === "object" ? data.criteria_details : {}, } } export async function evaluateFlowchart( job: FlowchartJobData, { isFinalAttempt = true }: { isFinalAttempt?: boolean } = {}, ) { const [row] = await db .select({ flowchart: schema.flowchartSubmission, problem: schema.problem }) .from(schema.flowchartSubmission) .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)) .where(eq(schema.flowchartSubmission.id, job.submissionId)) .limit(1) if (!row || ![0, 1].includes(row.flowchart.status)) return await db .update(schema.flowchartSubmission) .set({ status: 1 }) .where(eq(schema.flowchartSubmission.id, row.flowchart.id)) const started = performance.now() try { const reference = row.problem.mermaidCode ? `\n标准答案参考:\n${row.problem.mermaidCode}` : "\n此题没有标准流程图。" const result = parseEvaluation( await completeChat( evaluationPrompt(row.problem), `学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`, ), ) await db .update(schema.flowchartSubmission) .set({ status: 2, aiScore: result.score, aiGrade: result.grade, aiFeedback: result.feedback, aiSuggestions: result.suggestions, aiCriteriaDetails: result.criteria, aiProvider: config.aiProvider, aiModel: config.aiModel, processingTime: (performance.now() - started) / 1000, evaluationTime: new Date().toISOString(), }) .where(eq(schema.flowchartSubmission.id, row.flowchart.id)) await publishFlowchartUpdate(row.flowchart.userId, { type: "flowchart_evaluation_completed", submissionId: row.flowchart.id, score: result.score, grade: result.grade, feedback: result.feedback, suggestions: result.suggestions, criteriaDetails: result.criteria, } satisfies FlowchartUpdate) } catch (error) { // 原来这里把 error.message 原样推给学生、前端还直接 message.error 弹出来 —— // AI provider 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里, // 学生只需要知道「失败了,再试一次」;error 字段留空,前端有兜底文案。 console.error(`Failed to evaluate flowchart ${row.flowchart.id}`, error) // 只有最后一次尝试才落 FAILED。中间几次必须把状态留在 PROCESSING(1): // 上面那道 `![0, 1].includes(status)` 的守卫会把状态为 3 的任务直接放行返回, // 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做 // 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。 if (!isFinalAttempt) throw error await db .update(schema.flowchartSubmission) .set({ status: 3 }) .where(eq(schema.flowchartSubmission.id, row.flowchart.id)) await publishFlowchartUpdate(row.flowchart.userId, { type: "flowchart_evaluation_failed", submissionId: row.flowchart.id, } satisfies FlowchartUpdate) throw error } }