新增解答题拍照判分:百炼 Qwen-VL 按评分点逐点判分,无 API Key 时演示判分回退;混合卷按真实分值加权计分;判分照片与评分点进错题本;补齐历史迁移缺口(0005 模板生效方式/0006 五张缺失表+grammar_progress point_id)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-17 16:24:23 +08:00
co-authored by Claude Opus 4.8
parent 4033abc21d
commit f80a931ef0
21 changed files with 1377 additions and 45 deletions
+174
View File
@@ -0,0 +1,174 @@
// 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 };
}
+27 -2
View File
@@ -1,12 +1,13 @@
// xuchao: 考试板块——试卷查询与作答记录
import { and, eq, sql } from "drizzle-orm";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { db } from "@/db";
import { examPapers, examRecords, taskInstances } from "@/db/schema";
import { aiGradeLog, examPapers, examRecords, taskInstances } from "@/db/schema";
import { todayStr } from "@/lib/dates";
import type { QuizQ } from "@/lib/quiz-parse";
// xuchao: 试卷题型标签(展示用)
export const EXAM_TYPE_LABEL: Record<string, string> = {
entrance: "入学考",
monthly: "月考",
unit: "单元卷",
midterm: "期中",
@@ -139,3 +140,27 @@ export async function recordExam(
});
}
}
// xuchao: 当日 AI 判分次数(限频用,含全部状态)
export async function gradeCountToday(childId: number): Promise<number> {
const midnight = new Date(`${todayStr()}T00:00:00`);
const [r] = await db
.select({ n: sql<number>`COUNT(*)` })
.from(aiGradeLog)
.where(and(eq(aiGradeLog.childUserId, childId), gte(aiGradeLog.createdAt, midnight)));
return Number(r?.n ?? 0);
}
// xuchao: 某孩子某卷每道解答题最近一次判分日志(交卷计分用)
export async function latestSolveLogs(childId: number, paperId: string) {
const rows = await db
.select()
.from(aiGradeLog)
.where(and(eq(aiGradeLog.childUserId, childId), eq(aiGradeLog.paperId, paperId)))
.orderBy(desc(aiGradeLog.id));
const byQ = new Map<number, (typeof rows)[number]>();
for (const r of rows) {
if (!byQ.has(r.questionIndex)) byQ.set(r.questionIndex, r);
}
return byQ;
}
+12 -9
View File
@@ -63,19 +63,22 @@ export async function passedCount(childId: number): Promise<number> {
const norm = (s: unknown) => String(s ?? "").trim().toLowerCase();
// 服务端判分:五种题型 choice/judge/fill/order/match
// 服务端判分:六种题型 choice/judge/fill/order/match/solve(solve 不在此处判,走 AI)
export type AnswerValue = number | string | string[];
// xuchao: 单题客观判分(solve 返回 false,由 AI 判分链路单独处理)
export function gradeOne(q: QuizQ, a: AnswerValue | null | undefined): boolean {
if (q.type === "fill") return (q.answers ?? []).some((x) => norm(x) === norm(a));
if (q.type === "order") return Array.isArray(a) && a.join(" ").trim() === (q.answer as string[]).join(" ").trim();
if (q.type === "match") return Array.isArray(a) && (q.pairs ?? []).every((p, pi) => norm(a[pi]) === norm(p[1]));
if (q.type === "solve") return false;
return Number(a) === (q.answer as number);
}
export function gradeAnswers(questions: QuizQ[], answers: AnswerValue[]) {
let correct = 0;
const detail = questions.map((q, i) => {
const a = answers[i];
let ok = false;
if (q.type === "fill") ok = (q.answers ?? []).some((x) => norm(x) === norm(a));
else if (q.type === "order")
ok = Array.isArray(a) && a.join(" ").trim() === (q.answer as string[]).join(" ").trim();
else if (q.type === "match")
ok = Array.isArray(a) && (q.pairs ?? []).every((p, pi) => norm(a[pi]) === norm(p[1]));
else ok = Number(a) === (q.answer as number);
const ok = gradeOne(q, answers[i]);
if (ok) correct++;
return ok;
});
+16
View File
@@ -36,3 +36,19 @@ export function viewUrl(key: string) {
export function photoKey(childId: number, date: string) {
return `photos/${childId}/${date}/${crypto.randomBytes(8).toString("hex")}.jpg`;
}
// xuchao: 是否已配置 OSS(未配置时本地开发走 /api/dev-photo 落盘回退)
export function ossConfigured() {
return !!(BUCKET && ID && SECRET);
}
// xuchao: 解答题作答照片键名:exam-solve/孩子ID/试卷/题号-随机.jpg
export function solveKey(childId: number, paperId: string, questionIndex: number) {
return `exam-solve/${childId}/${paperId}/q${questionIndex}-${crypto.randomBytes(8).toString("hex")}.jpg`;
}
// xuchao: 作答照片展示地址:OSS 签名 GET;开发环境返回本地回退路由地址
export function photoViewUrl(key: string) {
if (ossConfigured()) return viewUrl(key);
return `/api/dev-photo?key=${encodeURIComponent(key)}`;
}
+8 -1
View File
@@ -1,13 +1,20 @@
// 纯解析工具:不依赖数据库,可安全在客户端组件中使用
export type QuizQ = {
type: "choice" | "fill" | "judge" | "order" | "match";
type: "choice" | "fill" | "judge" | "order" | "match" | "solve";
stem: string;
options?: string[];
answer?: number | string[];
answers?: string[];
words?: string[]; // order:打乱的词
pairs?: [string, string][]; // match:左右配对
// xuchao: solve 解答题(拍照 + AI 按评分点判分)
figure?: string; // 题目配图路径(public/exam/<卷>/xxx)
points?: number; // solve:本题满分
reference?: string; // solve:完整参考解答
rubric?: { point: string; score: number }[]; // solve:评分点,合计 = points
// xuchao: 错题本里的解答题作答快照(照片 + 逐点判定),交卷入错题本时写入
solveSnap?: { ossKey: string; earned: number; hits: { point: string; score: number; hit: boolean; reason: string }[] };
explain: string;
};
+8 -3
View File
@@ -6,6 +6,7 @@ import type { AnswerValue } from "@/lib/grammar";
// 各类题型的"用户作答"与"标准答案"可读文本
export function answerText(q: QuizQ, a: AnswerValue | null | undefined): string {
if (q.type === "solve") return `拍照作答,得 ${q.solveSnap?.earned ?? 0} / ${q.points ?? 0} 分`;
if (a == null) return "未作答";
if (q.type === "fill") return String(a);
if (q.type === "order") return Array.isArray(a) ? a.join(" ") : String(a);
@@ -14,6 +15,7 @@ export function answerText(q: QuizQ, a: AnswerValue | null | undefined): string
return opts[Number(a)] ?? String(a);
}
export function correctText(q: QuizQ): string {
if (q.type === "solve") return q.reference ?? "";
if (q.type === "fill") return (q.answers ?? []).join(" / ");
if (q.type === "order") return (q.answer as string[]).join(" ");
if (q.type === "match") return (q.pairs ?? []).map((p) => `${p[0]}→${p[1]}`).join("、");
@@ -23,6 +25,7 @@ export function correctText(q: QuizQ): string {
// 错题入库(同一孩子同一来源同一题干去重)
// sourceType:lesson/weekly=英语,mlesson/mweekly=数学,exam=考试卷
// solveExtra:解答题的作答快照(照片 key + 逐评分点判定),并入题目 JSON 供错题本回看
export async function recordWrong(
childId: number,
sourceType: "lesson" | "weekly" | "mlesson" | "mweekly" | "exam",
@@ -30,7 +33,9 @@ export async function recordWrong(
sourceTitle: string,
q: QuizQ,
user: AnswerValue | null | undefined,
solveExtra?: NonNullable<QuizQ["solveSnap"]>,
) {
const stored: QuizQ = q.type === "solve" ? { ...q, solveSnap: solveExtra } : q;
const rows = await db
.select({ question: wrongBook.question })
.from(wrongBook)
@@ -48,9 +53,9 @@ export async function recordWrong(
sourceType,
sourceId,
sourceTitle,
question: JSON.stringify(q),
userAnswer: answerText(q, user),
correctAnswer: correctText(q),
question: JSON.stringify(stored),
userAnswer: answerText(stored, user),
correctAnswer: correctText(stored),
});
}