// xuchao: 解答题 AI 判分引擎——阿里云百炼 Qwen-VL,按评分点(rubric)部分给分 // 无 DASHSCOPE_API_KEY 时进入演示判分(mock),供本地开发;生产必须配真 key export type SolveStatus = "ok" | "blank" | "blurry" | "printed" | "unrelated" | "error"; export type RubricItem = { point: string; score: number }; export type RubricHit = { point: string; score: number; hit: boolean; reason: string }; export type GradeVerdict = { status: SolveStatus; hits: RubricHit[]; earned: number; // 由 hits 重算,不信任模型自报 comment: string; mock: boolean; model: string; ms: number; }; const ENDPOINT = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"; const MODEL = process.env.AI_GRADE_MODEL || "qwen-vl-max"; const TIMEOUT_MS = 60_000; // xuchao: 判分提示词——题干+参考解答+评分点+学生手写照片,只允许离散给分 function buildPrompt(stem: string, points: number, reference: string, rubric: RubricItem[], hasFigure: boolean) { const rubricText = rubric.map((r, i) => `${i + 1}. ${r.point}(${r.score} 分)`).join("\n"); return `你是一位经验丰富的数学阅卷老师,正在批改学生手写的解答题照片。 【题目】${stem}${hasFigure ? "\n(题目配有示意图,以题意为准)" : ""} 【本题满分】${points} 分 【参考解答】 ${reference} 【评分点】 ${rubricText} 请按以下步骤评判: 1. 判断照片状态,status 只能是以下之一: - ok:正常的手写解答 - blank:空白或没有作答内容 - blurry:字迹模糊无法辨认 - printed:印刷体内容(非手写,拒绝批改) - unrelated:照片内容与本题无关 2. 仅当 status=ok 时逐条评判评分点。学生方法可以与参考解答不同,只要该评分点的要求确实达成就 hit=true 并给该评分点满分,否则 hit=false 给 0 分。不允许给某个评分点部分分数。 3. earned = 所有 hit=true 评分点分数之和,不得超过满分 ${points}。 4. comment 给一句面向小学生的总体点评(指出亮点或主要问题)。 只输出一个 JSON 对象,不要输出任何其他文字: {"status":"ok","hits":[{"hit":true,"reason":"简短理由"}],"earned":数字,"comment":"点评"} hits 数组元素个数必须等于评分点个数(${rubric.length}),顺序与评分点一致。status 非 ok 时 hits 为空数组、earned 为 0。`; } // xuchao: 演示判分(无 key):确定性模拟——最后一个评分点不得分,便于本地验证部分给分与错题链路 function mockVerdict(stem: string, points: number, rubric: RubricItem[]): GradeVerdict { const hits: RubricHit[] = rubric.map((r, i) => ({ point: r.point, score: r.score, hit: i < rubric.length - 1 || rubric.length === 1, reason: i < rubric.length - 1 || rubric.length === 1 ? "演示判分:达成" : "演示判分:未达成(模拟扣分)", })); const earned = hits.reduce((s, h) => s + (h.hit ? h.score : 0), 0); return { status: "ok", hits, earned: rubric.length === 1 ? points : Math.min(earned, points), comment: "这是演示判分(未配置百炼 API Key),结果仅用于流程测试。", mock: true, model: "mock", ms: 0, }; } // xuchao: 从模型回复里稳健提取 JSON(容忍代码块包裹与多余文字) function extractJson(text: string): Record | null { const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); const body = fenced ? fenced[1] : text; const start = body.indexOf("{"); const end = body.lastIndexOf("}"); if (start < 0 || end <= start) return null; try { return JSON.parse(body.slice(start, end + 1)); } catch { return null; } } const VALID_STATUS = new Set(["ok", "blank", "blurry", "printed", "unrelated"]); // xuchao: 校验并归一化模型判定;earned 一律按 hits 重算 function normalizeVerdict(raw: Record, rubric: RubricItem[], model: string, ms: number): GradeVerdict | null { const status = typeof raw.status === "string" && VALID_STATUS.has(raw.status) ? (raw.status as SolveStatus) : null; if (!status) return null; let hits: RubricHit[] = []; if (status === "ok") { const arr = Array.isArray(raw.hits) ? raw.hits : null; if (!arr || arr.length !== rubric.length) return null; hits = rubric.map((r, i) => { const h = (arr[i] ?? {}) as Record; return { point: r.point, score: r.score, hit: h.hit === true, reason: typeof h.reason === "string" ? h.reason : "" }; }); } const earned = Math.min( points_cap(rubric), hits.reduce((s, h) => s + (h.hit ? h.score : 0), 0), ); return { status, hits, earned, comment: typeof raw.comment === "string" ? raw.comment : "", mock: false, model, ms, }; } const points_cap = (rubric: RubricItem[]) => rubric.reduce((s, r) => s + r.score, 0); // xuchao: 判分主入口;photoUrl 必须是公网可达地址(OSS 签名 URL) export async function gradeSolvePhoto(opts: { stem: string; points: number; rubric: RubricItem[]; reference: string; photoUrl: string; figureUrl?: string; }): Promise { const { stem, points, rubric, reference, photoUrl, figureUrl } = opts; const apiKey = process.env.DASHSCOPE_API_KEY; if (!apiKey) return mockVerdict(stem, points, rubric); const content: Record[] = [ { type: "text", text: buildPrompt(stem, points, reference, rubric, !!figureUrl) }, { type: "image_url", image_url: { url: photoUrl } }, ]; if (figureUrl) content.push({ type: "image_url", image_url: { url: figureUrl } }); let lastErr = ""; for (let attempt = 0; attempt < 2; attempt++) { const t0 = Date.now(); try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); const resp = await fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: MODEL, messages: [{ role: "user", content }], temperature: 0.1, }), signal: ctrl.signal, }); clearTimeout(timer); if (!resp.ok) { lastErr = `HTTP ${resp.status}`; continue; } const data = (await resp.json()) as { choices?: { message?: { content?: string } }[] }; const text = data.choices?.[0]?.message?.content ?? ""; const raw = extractJson(text); if (!raw) { lastErr = "JSON 解析失败"; continue; } const v = normalizeVerdict(raw, rubric, MODEL, Date.now() - t0); if (!v) { lastErr = "判定结构不符"; continue; } return v; } catch (e) { lastErr = e instanceof Error ? e.message : String(e); } } // xuchao: 两次均失败——返回 error 状态,前端提示重拍,不写入得分 return { status: "error", hits: [], earned: 0, comment: `判分失败(${lastErr}),请重试`, mock: false, model: MODEL, ms: 0 }; }