孩子端应用题 answer 模式:拍照+填最终答案,交卷归一化匹配秒判(rubric 流程不变)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
jacobxu666
2026-08-20 14:07:12 +08:00
co-authored by Claude Haiku 4.5
parent e896b2be51
commit 255c9a244c
4 changed files with 98 additions and 5 deletions
+21
View File
@@ -4,6 +4,8 @@
import { revalidatePath } from "next/cache";
import { getSessionUser } from "@/lib/auth";
import { gradeOne, gradeAnswers, type AnswerValue } from "@/lib/grammar";
import { solveAnswerHit } from "@/lib/answer-match";
import type { SolveAnswerPayload } from "@/lib/quiz-parse";
import { getPaper, gradeCountToday, latestSolveLogs, paperQuestions, recordExam } from "@/lib/exam";
import { recordWrong } from "@/lib/wrongbook";
import { gradeSolvePhoto, type GradeVerdict } from "@/lib/ai-grade";
@@ -135,6 +137,25 @@ export async function takeExam(paperId: string, answers: (AnswerValue | null)[])
const pts = q.points ?? 1;
pointSum += pts;
if (q.type === "solve") {
if (q.judgeMode === "answer") {
// xuchao: answer 模式——最终答案归一化匹配秒判,不调 AI 不写审计日志
const payload = answers[i] as SolveAnswerPayload | null | undefined;
const given = payload?.answer?.trim() ?? "";
const hit = solveAnswerHit(q, given);
const earned = hit ? pts : 0;
detail.push(hit);
if (hit) correct++;
earnedSum += earned;
const hits: GradeVerdict["hits"] = [
{ point: "最终答案", score: earned, hit, reason: hit ? "与最终答案一致" : `期望:${(q.answers ?? []).join(" / ")},实际:${given || "未作答"}` },
];
solves.push({ qIndex: i, ossKey: payload?.ossKey ?? "", status: hit ? "ok" : "wrong", earned, points: pts, hits, comment: "" });
storedAnswers.push({ type: "solve", qIndex: i, ossKey: payload?.ossKey ?? "", earned, points: pts });
if (!hit) {
void recordWrong(user.id, "exam", paperId, paper.title, q, null, { ossKey: payload?.ossKey ?? "", earned, hits });
}
return;
}
const log = logs.get(i);
const okLog = log && log.status === "ok" ? log : null;
const earned = okLog ? Math.min(okLog.earned, pts) : 0;
+58 -4
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { Camera, Check, Loader2, RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { QuizQ } from "@/lib/quiz-parse";
import type { QuizQ, SolveAnswerPayload } from "@/lib/quiz-parse";
import type { AnswerValue } from "@/lib/grammar";
import type { GradeVerdict } from "@/lib/ai-grade";
import { getSolveUploadUrl, gradeSolve } from "@/app/exam/actions";
@@ -18,7 +18,9 @@ type SolveState =
| { phase: "idle" }
| { phase: "preview"; blob: Blob; dataUrl: string }
| { phase: "grading"; dataUrl: string }
| { phase: "graded"; dataUrl: string; photoUrl: string; verdict: GradeVerdict };
| { phase: "graded"; dataUrl: string; photoUrl: string; verdict: GradeVerdict }
// xuchao: answer 模式照片传完即停,填最终答案待交卷秒判
| { phase: "uploaded"; dataUrl: string; ossKey: string; answer: string };
type CellValue = AV | SolveState;
@@ -56,6 +58,10 @@ export function QuizBlock({
const isAnswered = (q: QuizQ, a: CellValue): boolean => {
if (q.type === "solve") {
if (q.judgeMode === "answer")
return (
!!a && typeof a === "object" && "phase" in a && a.phase === "uploaded" && (a.answer?.trim() ?? "") !== ""
);
return !!a && typeof a === "object" && "phase" in a && a.phase === "graded" && a.verdict.status === "ok";
}
if (q.type === "fill") return typeof a === "string" && a.trim() !== "";
@@ -68,8 +74,15 @@ export function QuizBlock({
const submit = () => {
setError("");
startTransition(async () => {
// xuchao: 解答题得分以服务端判分日志为准,客户端只传 null 占位
const payload = answers.map((a, i) => (questions[i].type === "solve" ? null : (a as AnswerValue)));
// xuchao: rubric 解答题以服务端判分日志为准传 null;answer 模式传最终答案+照片 key
const payload = answers.map((a, i) => {
const q = questions[i];
if (q.type !== "solve") return a as AnswerValue;
if (q.judgeMode === "answer" && a && typeof a === "object" && "phase" in a && a.phase === "uploaded") {
return { answer: a.answer ?? "", ossKey: a.ossKey } as SolveAnswerPayload;
}
return null;
});
const r = submitter
? await submitter(payload as AnswerValue[])
: await submitQuiz(pointId, payload as AnswerValue[]);
@@ -142,6 +155,7 @@ export function QuizBlock({
<SolveInput
paperId={pointId}
qIndex={qi}
judgeMode={q.judgeMode ?? "rubric"}
value={a as SolveState | null}
disabled={!!result}
onChange={(v) => set(qi, v)}
@@ -221,12 +235,14 @@ export function QuizBlock({
function SolveInput({
paperId,
qIndex,
judgeMode,
value,
disabled,
onChange,
}: {
paperId: string;
qIndex: number;
judgeMode: "rubric" | "answer";
value: SolveState | null;
disabled: boolean;
onChange: (v: SolveState) => void;
@@ -235,6 +251,7 @@ function SolveInput({
const [busy, setBusy] = useState("");
const [err, setErr] = useState("");
const st = value ?? { phase: "idle" as const };
const isAnswerMode = judgeMode === "answer";
// xuchao: 压缩到 ≤2MB(先缩到 2000px 内,再降质量)
const compress = (file: File) =>
@@ -306,6 +323,11 @@ function SolveInput({
const resp = await fetch("/api/dev-photo", { method: "POST", body: fd });
if (!resp.ok) throw new Error("照片上传失败,请重试");
}
if (isAnswerMode) {
// xuchao: answer 模式不调 AI,照片存档后等待最终答案提交秒判
onChange({ phase: "uploaded", dataUrl, ossKey: up.key, answer: "" });
return;
}
setBusy("AI 判分中…");
let r = await gradeSolve(paperId, qIndex, up.key);
// xuchao: 调用失败自动重试一次
@@ -347,6 +369,38 @@ function SolveInput({
);
}
if (st.phase === "uploaded") {
return (
<div className="mt-2 space-y-2">
<input
ref={fileRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
void onPick(e.target.files?.[0]);
e.target.value = "";
}}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={st.dataUrl} alt="解题照片(已上传)" className="max-h-40 rounded-lg border border-slate-200 object-contain" />
<p className="text-xs text-emerald-600">✅ 解题过程已上传</p>
<input
type="text"
value={st.answer}
disabled={disabled}
onChange={(e) => onChange({ ...st, answer: e.target.value })}
placeholder="填写最终答案,如:1/2 或 0.5"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/>
<button type="button" onClick={() => fileRef.current?.click()} disabled={disabled} className="text-xs text-indigo-600">
重新拍照
</button>
</div>
);
}
const dataUrl = st.phase === "preview" || st.phase === "grading" || st.phase === "graded" ? st.dataUrl : "";
return (
<div className="mt-2 space-y-2">
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { answersMatch, normalizeAnswer } from "./answer-match";
import { answersMatch, normalizeAnswer, solveAnswerHit } from "./answer-match";
describe("normalizeAnswer", () => {
it("分数转小数", () => {
@@ -42,4 +42,16 @@ describe("answersMatch", () => {
it("空期望数组不判对", () => {
expect(answersMatch([], "0.5")).toBe(false);
});
});
describe("solveAnswerHit", () => {
const q = { answers: ["2", "x=2"] };
it("命中最终答案", () => {
expect(solveAnswerHit({ answers: ["2"] }, "2")).toBe(true);
expect(solveAnswerHit(q, " x = 2 ")).toBe(true);
});
it("未命中", () => {
expect(solveAnswerHit(q, "3")).toBe(false);
expect(solveAnswerHit({ answers: [] }, "2")).toBe(false);
});
});
+6
View File
@@ -1,4 +1,5 @@
// xuchao: 答案等价判定——数值归一(分数/小数/百分数互转),文本归一(去空白/全角/小写)
import type { QuizQ } from "@/lib/quiz-parse";
const toHalf = (s: string) =>
s.replace(/[!-~]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)).replace(/ /g, " ");
@@ -24,4 +25,9 @@ export function answersMatch(expected: string[], given: string): boolean {
const g = normalizeAnswer(given);
if (!g) return false;
return expected.some((x) => normalizeAnswer(x) === g);
}
// xuchao: 应用题 answer 模式判定——最终答案等值即判对
export function solveAnswerHit(q: Pick<QuizQ, "answers">, given: string): boolean {
return answersMatch(q.answers ?? [], given);
}