新增解答题拍照判分:百炼 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
+156
-12
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user