Files
OJ2/apps/api/src/flowchart/run.ts
T
xuyue d25b5dbe6d
Deploy / deploy (push) Canceled after 0s
update
2026-09-27 19:36:28 +08:00

122 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>
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
}
}