From 255c9a244cdb8ea2d4797a71ddc9e0e49dc79e32 Mon Sep 17 00:00:00 2001 From: jacobxu666 Date: Thu, 20 Aug 2026 14:07:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=A9=E5=AD=90=E7=AB=AF=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E9=A2=98=20answer=20=E6=A8=A1=E5=BC=8F:=E6=8B=8D=E7=85=A7+?= =?UTF-8?q?=E5=A1=AB=E6=9C=80=E7=BB=88=E7=AD=94=E6=A1=88,=E4=BA=A4?= =?UTF-8?q?=E5=8D=B7=E5=BD=92=E4=B8=80=E5=8C=96=E5=8C=B9=E9=85=8D=E7=A7=92?= =?UTF-8?q?=E5=88=A4(rubric=20=E6=B5=81=E7=A8=8B=E4=B8=8D=E5=8F=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Haiku 4.5 --- src/app/exam/actions.ts | 21 ++++++++++++ src/app/learn/quiz-block.tsx | 62 +++++++++++++++++++++++++++++++++--- src/lib/answer-match.test.ts | 14 +++++++- src/lib/answer-match.ts | 6 ++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/app/exam/actions.ts b/src/app/exam/actions.ts index 3f4d720..2f6834f 100644 --- a/src/app/exam/actions.ts +++ b/src/app/exam/actions.ts @@ -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; diff --git a/src/app/learn/quiz-block.tsx b/src/app/learn/quiz-block.tsx index 13de46a..297424e 100644 --- a/src/app/learn/quiz-block.tsx +++ b/src/app/learn/quiz-block.tsx @@ -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({ 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 ( +
+ { + void onPick(e.target.files?.[0]); + e.target.value = ""; + }} + /> + {/* eslint-disable-next-line @next/next/no-img-element */} + 解题照片(已上传) +

✅ 解题过程已上传

+ 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" + /> + +
+ ); + } + const dataUrl = st.phase === "preview" || st.phase === "grading" || st.phase === "graded" ? st.dataUrl : ""; return (
diff --git a/src/lib/answer-match.test.ts b/src/lib/answer-match.test.ts index 480785a..c046e62 100644 --- a/src/lib/answer-match.test.ts +++ b/src/lib/answer-match.test.ts @@ -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); + }); }); \ No newline at end of file diff --git a/src/lib/answer-match.ts b/src/lib/answer-match.ts index ec440ca..a30bfd0 100644 --- a/src/lib/answer-match.ts +++ b/src/lib/answer-match.ts @@ -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, given: string): boolean { + return answersMatch(q.answers ?? [], given); } \ No newline at end of file