Files
SuperJump/src/lib/ai-grade.ts
T

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 };
}