fix(流程图评分): 队列重试从没生效过、AI 调用不设超时、等级由模型自报
## 重试是摆设
`flowchartQueue` 配了 `attempts: 3` + 指数退避,但任务开头有一道守卫:
if (!row || ![0, 1].includes(row.flowchart.status)) return
而 catch 里第一件事就是把 status 写成 3(FAILED)。于是第 2、3 次尝试进来一看
状态是 3,直接 return、算作成功 —— **实际只跑了一次**。AI 侧的偶发失败(限流、
超时、网络抖动)永远等不到重试,学生看到「评分失败」只能自己重新提交。
改成只有最后一次尝试才落 FAILED,中间几次把状态留在 PROCESSING(1) 让守卫放行。
「是不是最后一次」由 worker 算好传进来:`attemptsMade` 是「此前已失败几次」,
当前这次还没计入,所以判据是 `attemptsMade + 1 >= attempts`。
实测(用没配 AI_KEY 这条必然失败的路径):修复前 t+1s 就落 FAILED、只评一次;
修复后评满 3 次,状态到 t+7s 才落 FAILED。
## fetch 不设超时
`completeChat` 直接 `fetch`,而 fetch 默认不超时。AI 侧一挂就把 worker 的并发位
(只有 2 个)一直占着,学生那边的按钮也就一直转。加 60 秒超时。
流式调用**不加**:那边超时会把正在推的长回答直接掐断,而客户端断开本来就能收尾。
## 等级不该由模型说了算
提示词里写死了 S/A/B/C 四档分数区间,但模型偶尔会给出「88 分配 S 级」这种自相
矛盾的结果,甚至直接吐「优秀」。脏值会一路串到等级分布图、等级筛选,以及
「A/S 才把流程图展示给学生」的判断里。改成一律由分数推出等级,模型自报的 grade
不再采信;score 本来就已经 clamp 到 0-100。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,22 +15,38 @@ function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
|
||||
题目:${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" || typeof data.grade !== "string") throw new Error("AI response is missing score or grade")
|
||||
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: Math.max(0, Math.min(100, data.score)),
|
||||
grade: data.grade,
|
||||
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) {
|
||||
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)
|
||||
@@ -69,6 +85,11 @@ export async function evaluateFlowchart(job: FlowchartJobData) {
|
||||
// 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, flowchartUpdateSchema.parse({
|
||||
type: "flowchart_evaluation_failed",
|
||||
|
||||
@@ -15,10 +15,18 @@ function requestBody(messages: ChatMessage[], stream: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式调用的超时。fetch 默认不超时,AI 侧一挂就会把 worker 的并发位一直占着,
|
||||
* 学生那边的按钮也就一直转。流式调用不设:那边超时会把正在推的长回答直接掐断,
|
||||
* 客户端断开本来就能收尾。
|
||||
*/
|
||||
const COMPLETE_TIMEOUT_MS = 60_000
|
||||
|
||||
export async function completeChat(system: string, user: string) {
|
||||
if (!config.aiKey) throw new Error("缺少 AI_KEY")
|
||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS),
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
||||
body: JSON.stringify(requestBody([
|
||||
{ role: "system", content: system },
|
||||
|
||||
@@ -18,7 +18,11 @@ const worker = new Worker<JudgeJobData>(
|
||||
|
||||
const flowchartWorker = new Worker<FlowchartJobData>(
|
||||
flowchartQueueName,
|
||||
async (job) => evaluateFlowchart(job.data),
|
||||
// attemptsMade 是「此前已经失败过几次」,当前这次还没计进去,
|
||||
// 所以最后一次尝试的判据是 attemptsMade + 1 >= attempts
|
||||
async (job) => evaluateFlowchart(job.data, {
|
||||
isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1),
|
||||
}),
|
||||
{ connection: createBlockingRedis(), concurrency: 2 },
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user