孩子端应用题 answer 模式:拍照+填最终答案,交卷归一化匹配秒判(rubric 流程不变)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
e896b2be51
commit
255c9a244c
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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("分数转小数", () => {
|
||||
@@ -43,3 +43,15 @@ describe("answersMatch", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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, " ");
|
||||
|
||||
@@ -25,3 +26,8 @@ export function answersMatch(expected: string[], given: string): boolean {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user