新增解答题拍照判分:百炼 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
+156 -12
View File
@@ -1,18 +1,97 @@
"use server";
// xuchao: 考试判分——按卷面题判分,错题进错题本,达标联动数学打卡
// xuchao: 考试判分——客观题即时判分;解答题拍照后 AI 按评分点判分,成绩以服务端日志为准
import { revalidatePath } from "next/cache";
import { getSessionUser } from "@/lib/auth";
import { gradeAnswers, type AnswerValue } from "@/lib/grammar";
import { getPaper, paperQuestions, recordExam } from "@/lib/exam";
import { gradeOne, gradeAnswers, type AnswerValue } from "@/lib/grammar";
import { getPaper, gradeCountToday, latestSolveLogs, paperQuestions, recordExam } from "@/lib/exam";
import { recordWrong } from "@/lib/wrongbook";
import { gradeSolvePhoto, type GradeVerdict } from "@/lib/ai-grade";
import { ossConfigured, photoViewUrl, solveKey, uploadUrl } from "@/lib/oss";
import { db } from "@/db";
import { aiGradeLog } from "@/db/schema";
// xuchao: 每人每天最多判分次数(防滥用)
const GRADE_DAILY_LIMIT = 30;
export type SolveDetail = {
qIndex: number;
ossKey: string;
status: string;
earned: number;
points: number;
hits: GradeVerdict["hits"];
comment: string;
};
export type ExamResult =
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[] }
| { score: number; passed: boolean; correct: number; total: number; detail: boolean[]; solves?: SolveDetail[] }
| { error: string };
// 提交试卷:服务端判分、记录成绩、错题入库;可重考,取最近一次成绩
export async function takeExam(paperId: string, answers: AnswerValue[]): Promise<ExamResult> {
// xuchao: 签发解答题照片直传地址(或本地开发回退)
export async function getSolveUploadUrl(
paperId: string,
questionIndex: number,
): Promise<{ mode: "oss"; url: string; key: string } | { mode: "dev"; key: string } | { error: string }> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
const key = solveKey(user.id, paperId, questionIndex);
if (!ossConfigured()) return { mode: "dev", key };
return { mode: "oss", url: uploadUrl(key, "image/jpeg"), key };
}
// xuchao: 单题判分——调 AI、写审计日志、返回逐评分点结果;限频 30 次/天
export async function gradeSolve(
paperId: string,
questionIndex: number,
ossKey: string,
): Promise<{ verdict: GradeVerdict; photoUrl: string } | { error: string }> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
const paper = await getPaper(paperId);
if (!paper) return { error: "试卷不存在" };
const questions = paperQuestions(paper);
const q = questions[questionIndex];
if (!q || q.type !== "solve") return { error: "题目不存在或不是解答题" };
// xuchao: 键名必须属于本人本卷,防止拿别人的 key 判分
const prefix = `exam-solve/${user.id}/${paperId}/`;
if (!ossKey.startsWith(prefix) || !ossKey.endsWith(".jpg")) return { error: "照片键名不合法" };
const used = await gradeCountToday(user.id);
if (used >= GRADE_DAILY_LIMIT) return { error: `今日 AI 判分次数已用完(${GRADE_DAILY_LIMIT} 次),明天再来吧` };
const points = q.points ?? 0;
// xuchao: 未配 OSS 时照片在本地磁盘,AI 模型读不到,强制演示判分
const verdict = await gradeSolvePhoto({
stem: q.stem,
points,
rubric: q.rubric ?? [],
reference: q.reference ?? "",
photoUrl: photoViewUrl(ossKey),
figureUrl: q.figure ? `/exam/${paper.paperId}/${q.figure}` : undefined,
});
await db.insert(aiGradeLog).values({
childUserId: user.id,
paperId,
questionIndex,
ossKey,
status: verdict.status,
earned: verdict.earned,
points,
verdict: JSON.stringify({ hits: verdict.hits, comment: verdict.comment }),
mock: verdict.mock,
model: verdict.model,
ms: verdict.ms,
});
return { verdict, photoUrl: photoViewUrl(ossKey) };
}
// xuchao: 提交试卷——纯客观卷走原逻辑;含解答题的卷按分值加权,解答题得分取服务端判分日志
export async function takeExam(paperId: string, answers: (AnswerValue | null)[]): Promise<ExamResult> {
const user = await getSessionUser();
if (!user || user.role !== "child") return { error: "会话无效,请重新激活设备" };
@@ -22,16 +101,81 @@ export async function takeExam(paperId: string, answers: AnswerValue[]): Promise
const questions = paperQuestions(paper);
if (questions.length === 0) return { error: "试卷暂无题目" };
const g = gradeAnswers(questions, answers);
questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
});
await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers);
const hasSolve = questions.some((q) => q.type === "solve");
if (!hasSolve) {
// 原有客观卷判分路径,保持行为不变
const g = gradeAnswers(questions, answers.map((a) => a as AnswerValue));
questions.forEach((q, i) => {
if (!g.detail[i]) void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
});
await recordExam(user.id, paperId, paper.title, g.score, g.correct, g.total, g.passed, answers);
revalidateAll(paperId);
return g;
}
// xuchao: 混合卷加权判分——解答题只信服务端最近一次判定,不信任客户端上报
const logs = await latestSolveLogs(user.id, paperId);
let earnedSum = 0;
let pointSum = 0;
let correct = 0;
const detail: boolean[] = [];
const solves: SolveDetail[] = [];
const storedAnswers: unknown[] = [];
questions.forEach((q, i) => {
const pts = q.points ?? 1;
pointSum += pts;
if (q.type === "solve") {
const log = logs.get(i);
const okLog = log && log.status === "ok" ? log : null;
const earned = okLog ? Math.min(okLog.earned, pts) : 0;
earnedSum += earned;
const full = earned >= pts;
if (full) correct++;
detail.push(full);
const hits = okLog ? (JSON.parse(okLog.verdict ?? "{}") as { hits?: GradeVerdict["hits"] }).hits ?? [] : [];
solves.push({
qIndex: i,
ossKey: log?.ossKey ?? "",
status: log?.status ?? "missing",
earned,
points: pts,
hits,
comment: okLog ? (JSON.parse(okLog.verdict ?? "{}") as { comment?: string }).comment ?? "" : "未完成判分",
});
storedAnswers.push({ type: "solve", qIndex: i, ossKey: log?.ossKey ?? "", earned, points: pts });
if (!full) {
void recordWrong(user.id, "exam", paperId, paper.title, q, null, {
ossKey: log?.ossKey ?? "",
earned,
hits,
});
}
} else {
const ok = gradeOne(q, answers[i]);
if (ok) {
correct++;
earnedSum += pts;
} else {
void recordWrong(user.id, "exam", paperId, paper.title, q, answers[i]);
}
detail.push(ok);
storedAnswers.push(answers[i] ?? null);
}
});
const score = pointSum ? Math.round((earnedSum / pointSum) * 100) : 0;
const passed = score >= 80;
await recordExam(user.id, paperId, paper.title, score, correct, questions.length, passed, storedAnswers);
revalidateAll(paperId);
return { score, passed, correct, total: questions.length, detail, solves };
}
function revalidateAll(paperId: string) {
revalidatePath("/exam");
revalidatePath(`/exam/${paperId}`);
revalidatePath("/kid");
revalidatePath("/parent");
revalidatePath("/wrongbook");
return g;
}
+3 -2
View File
@@ -8,15 +8,16 @@ import { cn } from "@/lib/utils";
import { EXAM_TYPE_LABEL, listPapers, recordsByPaper } from "@/lib/exam";
import { getSessionUser } from "@/lib/auth";
// xuchao: 月份分组展示顺序与标签(初一上:9月~次年1月)
// xuchao: 月份分组展示顺序与标签(入学分班考 + 初一上:9月~次年1月)
const MONTH_LABEL: Record<number, string> = {
0: "入学 · 分班考",
9: "9 月 · 有理数",
10: "10 月 · 整式的加减",
11: "11 月 · 一元一次方程",
12: "12 月 · 几何图形初步",
1: "1 月 · 期末全册",
};
const MONTH_ORDER = [9, 10, 11, 12, 1];
const MONTH_ORDER = [0, 9, 10, 11, 12, 1];
export default async function ExamHome() {
const user = await getSessionUser();