175 lines
6.9 KiB
TypeScript
175 lines
6.9 KiB
TypeScript
// 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<string, unknown> | 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<string, unknown>, 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<string, unknown>;
|
|
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<GradeVerdict> {
|
|
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<string, unknown>[] = [
|
|
{ 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 };
|
|
}
|