新增解答题拍照判分:百炼 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:
co-authored by
Claude Opus 4.8
parent
4033abc21d
commit
f80a931ef0
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user