新增解答题拍照判分:百炼 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
@@ -0,0 +1,49 @@
|
||||
// xuchao: 本地开发照片回退——OSS 未配置时,解答题照片直传/查看走本地磁盘
|
||||
// 生产环境配置了 OSS 或 NODE_ENV=production 时,本路由一律 404
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ossConfigured } from "@/lib/oss";
|
||||
|
||||
const BASE = path.join(process.cwd(), ".dev-uploads");
|
||||
|
||||
function disabled() {
|
||||
return ossConfigured() || process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
// xuchao: 键名白名单校验 + 防路径穿越
|
||||
function safePath(key: string): string | null {
|
||||
if (!/^exam-solve\/\d+\/[\w-]+\/q\d+-[0-9a-f]{16}\.jpg$/.test(key)) return null;
|
||||
const p = path.normalize(path.join(BASE, key));
|
||||
return p.startsWith(BASE + path.sep) ? p : null;
|
||||
}
|
||||
|
||||
// xuchao: 直传入口:multipart file + key
|
||||
export async function POST(req: NextRequest) {
|
||||
if (disabled()) return NextResponse.json({ error: "not available" }, { status: 404 });
|
||||
const form = await req.formData();
|
||||
const file = form.get("file");
|
||||
const key = form.get("key");
|
||||
if (!(file instanceof File) || typeof key !== "string") {
|
||||
return NextResponse.json({ error: "缺少文件或键名" }, { status: 400 });
|
||||
}
|
||||
const target = safePath(key);
|
||||
if (!target) return NextResponse.json({ error: "键名不合法" }, { status: 400 });
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
if (buf.byteLength > 8 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: "照片过大" }, { status: 400 });
|
||||
}
|
||||
fs.writeFileSync(target, buf);
|
||||
return NextResponse.json({ ok: true, key });
|
||||
}
|
||||
|
||||
// xuchao: 查看入口:?key=xxx
|
||||
export async function GET(req: NextRequest) {
|
||||
if (disabled()) return NextResponse.json({ error: "not available" }, { status: 404 });
|
||||
const key = req.nextUrl.searchParams.get("key") ?? "";
|
||||
const target = safePath(key);
|
||||
if (!target || !fs.existsSync(target)) return NextResponse.json({ error: "不存在" }, { status: 404 });
|
||||
const buf = fs.readFileSync(target);
|
||||
return new NextResponse(buf, { headers: { "Content-Type": "image/jpeg" } });
|
||||
}
|
||||
+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();
|
||||
|
||||
+269
-12
@@ -1,17 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition, useState } from "react";
|
||||
import { useTransition, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, X } from "lucide-react";
|
||||
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 { AnswerValue } from "@/lib/grammar";
|
||||
import type { GradeVerdict } from "@/lib/ai-grade";
|
||||
import { getSolveUploadUrl, gradeSolve } from "@/app/exam/actions";
|
||||
import { submitQuiz, type QuizResult } from "./actions";
|
||||
|
||||
type AV = AnswerValue | null;
|
||||
|
||||
// 结课检验:选择/判断/填空/连词成句/连线,提交后服务端判分,达标点亮并联动打卡
|
||||
// xuchao: 解答题拍照状态机
|
||||
type SolveState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "preview"; blob: Blob; dataUrl: string }
|
||||
| { phase: "grading"; dataUrl: string }
|
||||
| { phase: "graded"; dataUrl: string; photoUrl: string; verdict: GradeVerdict };
|
||||
|
||||
type CellValue = AV | SolveState;
|
||||
|
||||
// 结课检验:选择/判断/填空/连词成句/连线/解答题(拍照),提交后服务端判分,达标点亮并联动打卡
|
||||
// 文案可配置:数学课/考试卷复用此组件时传入各自的标题、打卡文案与返回地址
|
||||
export function QuizBlock({
|
||||
pointId,
|
||||
@@ -32,18 +43,21 @@ export function QuizBlock({
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
}) {
|
||||
const [answers, setAnswers] = useState<AV[]>(questions.map(() => null));
|
||||
const [answers, setAnswers] = useState<CellValue[]>(questions.map(() => null));
|
||||
const [result, setResult] = useState<Extract<QuizResult, { score: number }> | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const set = (i: number, v: AV) => {
|
||||
const set = (i: number, v: CellValue) => {
|
||||
const next = [...answers];
|
||||
next[i] = v;
|
||||
setAnswers(next);
|
||||
};
|
||||
|
||||
const isAnswered = (q: QuizQ, a: AV): boolean => {
|
||||
const isAnswered = (q: QuizQ, a: CellValue): boolean => {
|
||||
if (q.type === "solve") {
|
||||
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() !== "";
|
||||
if (q.type === "order") return Array.isArray(a) && a.length === (q.words ?? []).length;
|
||||
if (q.type === "match") return Array.isArray(a) && a.length === (q.pairs ?? []).length && a.every((x) => x !== null && x !== "");
|
||||
@@ -54,9 +68,11 @@ export function QuizBlock({
|
||||
const submit = () => {
|
||||
setError("");
|
||||
startTransition(async () => {
|
||||
// xuchao: 解答题得分以服务端判分日志为准,客户端只传 null 占位
|
||||
const payload = answers.map((a, i) => (questions[i].type === "solve" ? null : (a as AnswerValue)));
|
||||
const r = submitter
|
||||
? await submitter(answers.map((a) => a as AnswerValue))
|
||||
: await submitQuiz(pointId, answers.map((a) => a as AnswerValue));
|
||||
? await submitter(payload as AnswerValue[])
|
||||
: await submitQuiz(pointId, payload as AnswerValue[]);
|
||||
if ("error" in r) setError(r.error);
|
||||
else setResult(r);
|
||||
});
|
||||
@@ -74,6 +90,7 @@ export function QuizBlock({
|
||||
<div key={qi} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4">
|
||||
<p className="font-medium text-slate-900">
|
||||
{qi + 1}. {q.stem}
|
||||
{q.type === "solve" && <span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-700">{q.points} 分 · 拍照作答</span>}
|
||||
</p>
|
||||
|
||||
{q.type === "fill" && (
|
||||
@@ -117,11 +134,21 @@ export function QuizBlock({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "order" && <OrderInput q={q} a={a} disabled={!!result} onChange={(v) => set(qi, v)} />}
|
||||
{q.type === "order" && <OrderInput q={q} a={a as AV} disabled={!!result} onChange={(v) => set(qi, v)} />}
|
||||
|
||||
{q.type === "match" && <MatchInput q={q} a={a} disabled={!!result} onChange={(v) => set(qi, v)} />}
|
||||
{q.type === "match" && <MatchInput q={q} a={a as AV} disabled={!!result} onChange={(v) => set(qi, v)} />}
|
||||
|
||||
{result && (
|
||||
{q.type === "solve" && (
|
||||
<SolveInput
|
||||
paperId={pointId}
|
||||
qIndex={qi}
|
||||
value={a as SolveState | null}
|
||||
disabled={!!result}
|
||||
onChange={(v) => set(qi, v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result && q.type !== "solve" && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{q.type === "fill" && (
|
||||
<span className="mr-2 font-medium text-emerald-700">参考:{(q.answers ?? []).join(" / ")}</span>
|
||||
@@ -137,7 +164,14 @@ export function QuizBlock({
|
||||
解说:{q.explain}
|
||||
</p>
|
||||
)}
|
||||
{result && ok === false && <p className="mt-1 text-xs text-rose-600">这题答错了,看看上面的参考与解说。</p>}
|
||||
{result && q.type === "solve" && (
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p className="font-medium text-emerald-700">参考解答:</p>
|
||||
<p className="whitespace-pre-line rounded-lg bg-white px-3 py-2 text-slate-700 ring-1 ring-slate-200">{q.reference}</p>
|
||||
<p className="text-muted-foreground">解说:{q.explain}</p>
|
||||
</div>
|
||||
)}
|
||||
{result && ok === false && <p className="mt-1 text-xs text-rose-600">这题没拿满分,看看上面的参考与解说。</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -180,6 +214,229 @@ export function QuizBlock({
|
||||
);
|
||||
}
|
||||
|
||||
// xuchao: 解答题拍照输入——纸上写→拍照→压缩→直传→AI 逐评分点判分,可重拍
|
||||
function SolveInput({
|
||||
paperId,
|
||||
qIndex,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
paperId: string;
|
||||
qIndex: number;
|
||||
value: SolveState | null;
|
||||
disabled: boolean;
|
||||
onChange: (v: SolveState) => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const st = value ?? { phase: "idle" as const };
|
||||
|
||||
// xuchao: 压缩到 ≤2MB(先缩到 2000px 内,再降质量)
|
||||
const compress = (file: File) =>
|
||||
new Promise<{ blob: Blob; dataUrl: string }>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const scale = Math.min(1, 2000 / Math.max(img.width, img.height));
|
||||
const w = Math.max(1, Math.round(img.width * scale));
|
||||
const h = Math.max(1, Math.round(img.height * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return reject(new Error("浏览器不支持图片处理"));
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
let quality = 0.85;
|
||||
const attempt = () =>
|
||||
canvas.toBlob(
|
||||
(b) => {
|
||||
if (!b) return reject(new Error("图片压缩失败"));
|
||||
if (b.size > 2 * 1024 * 1024 && quality > 0.4) {
|
||||
quality -= 0.15;
|
||||
return attempt();
|
||||
}
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve({ blob: b, dataUrl: String(r.result) });
|
||||
r.onerror = () => reject(new Error("读取图片失败"));
|
||||
r.readAsDataURL(b);
|
||||
},
|
||||
"image/jpeg",
|
||||
quality,
|
||||
);
|
||||
attempt();
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("图片加载失败,请换一张"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
const onPick = async (file: File | undefined) => {
|
||||
if (!file || disabled) return;
|
||||
setErr("");
|
||||
try {
|
||||
const { blob, dataUrl } = await compress(file);
|
||||
onChange({ phase: "preview", blob, dataUrl });
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "处理照片失败");
|
||||
}
|
||||
};
|
||||
|
||||
const doGrade = async (blob: Blob, dataUrl: string) => {
|
||||
setErr("");
|
||||
setBusy("上传中…");
|
||||
onChange({ phase: "grading", dataUrl });
|
||||
try {
|
||||
const up = await getSolveUploadUrl(paperId, qIndex);
|
||||
if ("error" in up) throw new Error(up.error);
|
||||
if (up.mode === "oss") {
|
||||
const resp = await fetch(up.url, { method: "PUT", headers: { "Content-Type": "image/jpeg" }, body: blob });
|
||||
if (!resp.ok) throw new Error("照片上传失败,请重试");
|
||||
} else {
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, "solve.jpg");
|
||||
fd.append("key", up.key);
|
||||
const resp = await fetch("/api/dev-photo", { method: "POST", body: fd });
|
||||
if (!resp.ok) throw new Error("照片上传失败,请重试");
|
||||
}
|
||||
setBusy("AI 判分中…");
|
||||
let r = await gradeSolve(paperId, qIndex, up.key);
|
||||
// xuchao: 调用失败自动重试一次
|
||||
if ("error" in r && /失败|超时/.test(r.error)) r = await gradeSolve(paperId, qIndex, up.key);
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
onChange({ phase: "graded", dataUrl, photoUrl: r.photoUrl, verdict: r.verdict });
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "判分失败,请重试");
|
||||
onChange({ phase: "preview", blob, dataUrl });
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
};
|
||||
|
||||
if (st.phase === "idle") {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void onPick(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-indigo-300 bg-indigo-50/50 px-4 py-6 text-sm font-semibold text-indigo-700 hover:bg-indigo-50"
|
||||
>
|
||||
<Camera className="size-5" /> 在纸上写好,拍照上传
|
||||
</button>
|
||||
<p className="mt-1 text-xs text-muted-foreground">写清每一步过程,按评分点给分;请横拍、光线充足、字迹清楚。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dataUrl = st.phase === "preview" || st.phase === "grading" || st.phase === "graded" ? st.dataUrl : "";
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={dataUrl} alt="作答照片" className="max-h-64 w-full rounded-lg border border-slate-200 object-contain" />
|
||||
|
||||
{st.phase === "preview" && !disabled && (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={() => void doGrade(st.blob, st.dataUrl)}>
|
||||
<Check className="size-4" /> 就这张,开始判分
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => fileRef.current?.click()}>
|
||||
<RotateCcw className="size-4" /> 重拍
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void onPick(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{st.phase === "grading" && (
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-indigo-600">
|
||||
<Loader2 className="size-4 animate-spin" /> {busy || "AI 判分中…"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{st.phase === "graded" && (
|
||||
<>
|
||||
<SolveVerdict verdict={st.verdict} />
|
||||
{st.verdict.status !== "ok" && !disabled && (
|
||||
<Button size="sm" variant="outline" onClick={() => onChange({ phase: "idle" })}>
|
||||
<RotateCcw className="size-4" /> 重新拍照
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{err && <p className="text-sm font-medium text-destructive">{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// xuchao: 判分结果展示——逐评分点命中情况 + 得分 + AI 点评
|
||||
function SolveVerdict({ verdict }: { verdict: GradeVerdict }) {
|
||||
if (verdict.status !== "ok") {
|
||||
const tip: Record<string, string> = {
|
||||
blank: "照片里没有作答内容,请写完再拍。",
|
||||
blurry: "字迹太模糊啦,重新拍一张清楚点的。",
|
||||
printed: "只能批改手写解答,请把过程写在纸上再拍。",
|
||||
unrelated: "照片内容和这道题无关,请拍这道题的解答。",
|
||||
error: "AI 判分出了点问题,请重试。",
|
||||
};
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
{tip[verdict.status] ?? "请重新拍照"} {verdict.comment && <span className="text-amber-700">({verdict.comment})</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const total = verdict.hits.reduce((s, h) => s + h.score, 0);
|
||||
return (
|
||||
<div className="space-y-1.5 rounded-lg border border-indigo-100 bg-white px-3 py-2">
|
||||
<p className="text-sm font-bold text-slate-900">
|
||||
AI 判分:{verdict.earned} / {total} 分
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{verdict.hits.map((h, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5 text-xs">
|
||||
{h.hit ? (
|
||||
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<X className="mt-0.5 size-3.5 shrink-0 text-rose-500" />
|
||||
)}
|
||||
<span className={h.hit ? "text-slate-600" : "text-rose-600"}>
|
||||
({h.score}分){h.point}
|
||||
{h.reason && <span className="text-muted-foreground"> —— {h.reason}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{verdict.comment && <p className="text-xs text-indigo-600">💬 {verdict.comment}</p>}
|
||||
{verdict.mock && <p className="text-[10px] text-muted-foreground">演示判分(未配置 AI Key)</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 连词成句:点词入槽,再点槽里的词可移回
|
||||
function OrderInput({
|
||||
q,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
||||
import { BackgroundBlobs } from "@/components/background-blobs";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { listWrong } from "@/lib/wrongbook";
|
||||
import { photoViewUrl } from "@/lib/oss";
|
||||
import type { QuizQ } from "@/lib/quiz-parse";
|
||||
import { WrongRow } from "./wrong-row";
|
||||
|
||||
@@ -39,6 +40,8 @@ export default async function WrongBookPage() {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// xuchao: 解答题展示当时的作答照片(OSS 签名地址;开发环境为本地回退)
|
||||
const photoUrl = q.type === "solve" && q.solveSnap?.ossKey ? photoViewUrl(q.solveSnap.ossKey) : "";
|
||||
return (
|
||||
<WrongRow
|
||||
key={r.id}
|
||||
@@ -48,6 +51,7 @@ export default async function WrongBookPage() {
|
||||
userAnswer={r.userAnswer}
|
||||
correctAnswer={r.correctAnswer}
|
||||
mastered={r.mastered}
|
||||
photoUrl={photoUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { CheckCircle2, RotateCcw } from "lucide-react";
|
||||
import { Check, CheckCircle2, RotateCcw, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { QuizQ } from "@/lib/quiz-parse";
|
||||
@@ -14,6 +14,7 @@ export function WrongRow({
|
||||
userAnswer,
|
||||
correctAnswer,
|
||||
mastered,
|
||||
photoUrl = "",
|
||||
}: {
|
||||
id: number;
|
||||
sourceTitle: string | null;
|
||||
@@ -21,8 +22,10 @@ export function WrongRow({
|
||||
userAnswer: string | null;
|
||||
correctAnswer: string | null;
|
||||
mastered: boolean;
|
||||
photoUrl?: string;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const isSolve = q.type === "solve";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -31,14 +34,46 @@ export function WrongRow({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="font-medium text-slate-900">{q.stem}</p>
|
||||
<p className="font-medium text-slate-900">
|
||||
{q.stem}
|
||||
{isSolve && <span className="ml-2 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-semibold text-amber-700">解答题</span>}
|
||||
</p>
|
||||
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{sourceTitle ?? "检测"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isSolve && photoUrl && (
|
||||
// xuchao: 当时的作答照片(签名地址 5 分钟有效,页面刷新即重签)
|
||||
<div className="mt-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoUrl} alt="作答照片" className="max-h-56 rounded-lg border border-slate-200 object-contain" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSolve && (q.solveSnap?.hits?.length ?? 0) > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{q.solveSnap!.hits.map((h, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5 text-xs">
|
||||
{h.hit ? (
|
||||
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-600" />
|
||||
) : (
|
||||
<X className="mt-0.5 size-3.5 shrink-0 text-rose-500" />
|
||||
)}
|
||||
<span className={h.hit ? "text-slate-600" : "text-rose-600"}>
|
||||
({h.score}分){h.point}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p className="text-rose-600">你的答案:{userAnswer ?? "未作答"}</p>
|
||||
<p className="text-emerald-700">正确答案:{correctAnswer ?? ""}</p>
|
||||
<p className={cn("text-emerald-700", isSolve && "whitespace-pre-line")}>
|
||||
{isSolve ? "参考解答:" : "正确答案:"}
|
||||
{correctAnswer ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 rounded-lg bg-indigo-50 px-3 py-2 text-sm text-indigo-800">💡 {q.explain}</p>
|
||||
<div className="mt-3">
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
{
|
||||
"papers": [
|
||||
{
|
||||
"id": "g7s1-m00-entrance",
|
||||
"grade": "g7",
|
||||
"semester": "s1",
|
||||
"month": 0,
|
||||
"examType": "entrance",
|
||||
"title": "初一入学分班考数学模拟卷(含解答题)",
|
||||
"region": "通用",
|
||||
"year": 2026,
|
||||
"fullScore": 100,
|
||||
"sourceUrl": "",
|
||||
"summary": "按真实分班考卷面结构组卷:填空 8 题×3 分、选择 6 题×3 分、计算解答 4 题×5 分、应用解答 4 题共 38 分,满分 100 分。题目取自本地教研素材的经典分班考题型(鸡兔同笼、工程、分数百分数、行程等),经双模型独立验算;因抓取受限未用原卷页面转录。解答题需纸上手写后拍照,由 AI 按评分点逐步判分。",
|
||||
"questions": [
|
||||
{
|
||||
"type": "fill",
|
||||
"stem": "24 和 36 的最大公因数是____。",
|
||||
"answers": [
|
||||
"12"
|
||||
],
|
||||
"explain": "24=2×2×2×3,36=2×2×3×3,公有质因数相乘:2×2×3=12。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"stem": "8 和 12 的最小公倍数是____。",
|
||||
"answers": [
|
||||
"24"
|
||||
],
|
||||
"explain": "8=2×2×2,12=2×2×3,最小公倍数=2×2×2×3=24。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"stem": "一根绳子长 20 米,用去它的 3/5,还剩____米。",
|
||||
"answers": [
|
||||
"8"
|
||||
],
|
||||
"explain": "用去 20×3/5=12 米,还剩 20-12=8 米(或 20×(1-3/5)=20×2/5=8 米)。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"stem": "一个两位数,个位上的数字是十位上数字的 3 倍,且两个数位上的数字之和是 8,这个两位数是____。",
|
||||
"answers": [
|
||||
"26"
|
||||
],
|
||||
"explain": "设十位为 a,则个位为 3a,a+3a=8,a=2,个位是 6,这个数是 26。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"stem": "一个数除以 5,商是 8,余数是 2,这个数是____。",
|
||||
"answers": [
|
||||
"42"
|
||||
],
|
||||
"explain": "被除数=商×除数+余数=8×5+2=42。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"stem": "按规律填数:1,1,2,3,5,8,____。",
|
||||
"answers": [
|
||||
"13"
|
||||
],
|
||||
"explain": "从第三项起,每个数等于前两个数的和:5+8=13。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"stem": "把 120 按 2:3 分成两份,较大的一份是____。",
|
||||
"answers": [
|
||||
"72"
|
||||
],
|
||||
"explain": "总份数 2+3=5,较大一份占 3/5:120×3/5=72。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"stem": "一个圆的周长是 31.4 厘米,它的直径是____厘米。(π 取 3.14)",
|
||||
"answers": [
|
||||
"10"
|
||||
],
|
||||
"explain": "直径=周长÷π=31.4÷3.14=10 厘米。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
"stem": "1 元和 5 元的纸币共 12 张,合计 28 元,其中 5 元纸币有( )张。",
|
||||
"options": [
|
||||
"A. 3",
|
||||
"B. 4",
|
||||
"C. 5",
|
||||
"D. 6"
|
||||
],
|
||||
"answer": 1,
|
||||
"explain": "设 5 元 x 张,则 1 元 (12-x) 张:5x+(12-x)=28,4x=16,x=4。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
"stem": "一件衣服原价 200 元,打七五折出售,现价是( )元。",
|
||||
"options": [
|
||||
"A. 50",
|
||||
"B. 150",
|
||||
"C. 160",
|
||||
"D. 175"
|
||||
],
|
||||
"answer": 1,
|
||||
"explain": "七五折即按原价的 75% 出售:200×75%=150 元。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
"stem": "路程一定,行驶的速度和所需的时间( )。",
|
||||
"options": [
|
||||
"A. 成正比例",
|
||||
"B. 成反比例",
|
||||
"C. 不成比例"
|
||||
],
|
||||
"answer": 1,
|
||||
"explain": "速度×时间=路程(一定),乘积一定,成反比例。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
"stem": "下面各数中,是质数的是( )。",
|
||||
"options": [
|
||||
"A. 51",
|
||||
"B. 91",
|
||||
"C. 97",
|
||||
"D. 117"
|
||||
],
|
||||
"answer": 2,
|
||||
"explain": "51=3×17,91=7×13,117=3×39=9×13,都不是质数;97 只有 1 和它本身两个因数,是质数。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
"stem": "一个圆的半径扩大到原来的 2 倍,它的面积扩大到原来的( )倍。",
|
||||
"options": [
|
||||
"A. 2",
|
||||
"B. 4",
|
||||
"C. 6",
|
||||
"D. 8"
|
||||
],
|
||||
"answer": 1,
|
||||
"explain": "面积=πr²,半径扩大 2 倍,面积扩大 2²=4 倍。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
"stem": "爸爸今年 40 岁,儿子今年 12 岁,( )年后爸爸的年龄是儿子的 3 倍。",
|
||||
"options": [
|
||||
"A. 1",
|
||||
"B. 2",
|
||||
"C. 3",
|
||||
"D. 4"
|
||||
],
|
||||
"answer": 1,
|
||||
"explain": "设 x 年后:40+x=3×(12+x),40+x=36+3x,2x=4,x=2。",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"type": "solve",
|
||||
"stem": "用简便方法计算:25×32×125",
|
||||
"points": 5,
|
||||
"reference": "25×32×125\n=25×(4×8)×125(把 32 拆成 4×8)\n=(25×4)×(8×125)(乘法交换律、结合律)\n=100×1000\n=100000",
|
||||
"rubric": [
|
||||
{
|
||||
"point": "把 32 拆成 4×8,并合理运用乘法交换律/结合律重组算式",
|
||||
"score": 2
|
||||
},
|
||||
{
|
||||
"point": "正确算出 25×4=100 与 8×125=1000",
|
||||
"score": 2
|
||||
},
|
||||
{
|
||||
"point": "得出正确结果 100000",
|
||||
"score": 1
|
||||
}
|
||||
],
|
||||
"explain": "看到 25 想 4,看到 125 想 8:把 32 拆成 4×8,凑成 25×4 与 8×125 两组整百整千。"
|
||||
},
|
||||
{
|
||||
"type": "solve",
|
||||
"stem": "用简便方法计算:3/4×5/6+5/6×1/4",
|
||||
"points": 5,
|
||||
"reference": "3/4×5/6+5/6×1/4\n=5/6×(3/4+1/4)(乘法分配律逆用)\n=5/6×1\n=5/6",
|
||||
"rubric": [
|
||||
{
|
||||
"point": "发现公因数 5/6,逆用乘法分配律写成 5/6×(3/4+1/4)",
|
||||
"score": 2
|
||||
},
|
||||
{
|
||||
"point": "正确算出 3/4+1/4=1",
|
||||
"score": 1
|
||||
},
|
||||
{
|
||||
"point": "得出正确结果 5/6",
|
||||
"score": 2
|
||||
}
|
||||
],
|
||||
"explain": "两个乘积都有因数 5/6,逆用分配律:5/6×(3/4+1/4)=5/6×1=5/6。"
|
||||
},
|
||||
{
|
||||
"type": "solve",
|
||||
"stem": "解方程:3x+5=2x+9",
|
||||
"points": 5,
|
||||
"reference": "3x+5=2x+9\n3x-2x=9-5(两边同时减 2x、减 5)\nx=4",
|
||||
"rubric": [
|
||||
{
|
||||
"point": "正确移项(含未知数的项移到一边,常数项移到另一边,变号正确)",
|
||||
"score": 2
|
||||
},
|
||||
{
|
||||
"point": "得出 x=4",
|
||||
"score": 2
|
||||
},
|
||||
{
|
||||
"point": "写出结论或代入检验",
|
||||
"score": 1
|
||||
}
|
||||
],
|
||||
"explain": "移项:3x-2x=9-5,即 x=4。检验:左边=3×4+5=17,右边=2×4+9=17,相等。"
|
||||
},
|
||||
{
|
||||
"type": "solve",
|
||||
"stem": "计算:4.8÷(1.2×2)+3.5",
|
||||
"points": 5,
|
||||
"reference": "4.8÷(1.2×2)+3.5\n=4.8÷2.4+3.5(先算括号里的)\n=2+3.5\n=5.5",
|
||||
"rubric": [
|
||||
{
|
||||
"point": "按运算顺序先算括号内 1.2×2=2.4",
|
||||
"score": 1
|
||||
},
|
||||
{
|
||||
"point": "正确算出 4.8÷2.4=2",
|
||||
"score": 2
|
||||
},
|
||||
{
|
||||
"point": "得出正确结果 5.5",
|
||||
"score": 2
|
||||
}
|
||||
],
|
||||
"explain": "运算顺序:先括号,再乘除,最后加减。4.8÷2.4=2,2+3.5=5.5。"
|
||||
},
|
||||
{
|
||||
"type": "solve",
|
||||
"stem": "笼子里有鸡和兔共 35 只,数一数共有 94 条腿。鸡和兔各有多少只?",
|
||||
"points": 8,
|
||||
"reference": "假设全是鸡:35×2=70(条腿)\n比实际少:94-70=24(条腿)\n每把一只鸡换成兔多 2 条腿:24÷2=12(只)\n兔有 12 只,鸡有 35-12=23(只)\n答:鸡有 23 只,兔有 12 只。",
|
||||
"rubric": [
|
||||
{
|
||||
"point": "方法正确完整(假设法、方程法等任一种,数量关系列对)",
|
||||
"score": 3
|
||||
},
|
||||
{
|
||||
"point": "求出兔 12 只",
|
||||
"score": 3
|
||||
},
|
||||
{
|
||||
"point": "求出鸡 23 只并写出答句",
|
||||
"score": 2
|
||||
}
|
||||
],
|
||||
"explain": "鸡兔同笼经典题:假设全是鸡得 70 条腿,差的 24 条腿由每只换兔补 2 条,兔=24÷2=12 只。也可设兔 x 只:4x+2(35-x)=94。"
|
||||
},
|
||||
{
|
||||
"type": "solve",
|
||||
"stem": "一件工作,甲单独做 20 天完成,乙单独做 30 天完成。两人合作,多少天可以完成?",
|
||||
"points": 10,
|
||||
"reference": "把工作总量看作 1。\n甲每天完成 1/20,乙每天完成 1/30。\n两人每天共完成:1/20+1/30=3/60+2/60=5/60=1/12\n完成天数:1÷1/12=12(天)\n答:两人合作 12 天可以完成。",
|
||||
"rubric": [
|
||||
{
|
||||
"point": "设工作总量为 1,正确表示甲、乙工作效率 1/20 与 1/30",
|
||||
"score": 3
|
||||
},
|
||||
{
|
||||
"point": "正确求出效率和 1/20+1/30=1/12",
|
||||
"score": 4
|
||||
},
|
||||
{
|
||||
"point": "得出 12 天并写出答句",
|
||||
"score": 3
|
||||
}
|
||||
],
|
||||
"explain": "工程问题:工作总量÷效率和=合作时间。1÷(1/20+1/30)=1÷1/12=12 天。"
|
||||
},
|
||||
{
|
||||
"type": "solve",
|
||||
"stem": "商店运来一批水果,上午卖出总数的 1/3,下午卖出总数的 1/4,还剩 50 千克。这批水果共有多少千克?",
|
||||
"points": 10,
|
||||
"reference": "把水果总数看作单位“1”。\n50 千克对应的分率:1-1/3-1/4=12/12-4/12-3/12=5/12\n总数:50÷5/12=50×12/5=120(千克)\n答:这批水果共有 120 千克。",
|
||||
"rubric": [
|
||||
{
|
||||
"point": "找准单位“1”,正确求出 50 千克对应的分率 1-1/3-1/4=5/12",
|
||||
"score": 4
|
||||
},
|
||||
{
|
||||
"point": "正确列式 50÷5/12 并计算",
|
||||
"score": 3
|
||||
},
|
||||
{
|
||||
"point": "得出 120 千克并写出答句",
|
||||
"score": 3
|
||||
}
|
||||
],
|
||||
"explain": "分数应用题:已知剩余量求单位“1”,用除法。剩余对应分率 5/12,总数=50÷5/12=120 千克。"
|
||||
},
|
||||
{
|
||||
"type": "solve",
|
||||
"stem": "甲、乙两地相距 480 千米,一辆客车从甲地出发,每小时行 60 千米;同时一辆货车从乙地出发,每小时行 40 千米,两车相向而行。经过几小时两车相遇?相遇时客车行了多少千米?",
|
||||
"points": 10,
|
||||
"reference": "速度和:60+40=100(千米/时)\n相遇时间:480÷100=4.8(小时)\n客车行驶路程:60×4.8=288(千米)\n答:经过 4.8 小时两车相遇,相遇时客车行了 288 千米。",
|
||||
"rubric": [
|
||||
{
|
||||
"point": "正确求出速度和 60+40=100 千米/时",
|
||||
"score": 2
|
||||
},
|
||||
{
|
||||
"point": "得出相遇时间 480÷100=4.8 小时",
|
||||
"score": 4
|
||||
},
|
||||
{
|
||||
"point": "得出客车行了 288 千米并写出答句",
|
||||
"score": 4
|
||||
}
|
||||
],
|
||||
"explain": "相遇问题:相遇时间=总路程÷速度和=480÷100=4.8 小时;客车路程=60×4.8=288 千米。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -346,3 +346,30 @@ export const examRecords = mysqlTable(
|
||||
},
|
||||
(t) => [uniqueIndex("uq_exam_child_paper").on(t.childUserId, t.paperId)],
|
||||
);
|
||||
|
||||
// ===== AI 判分审计日志(限频依据 + 家长端可查 + 成绩溯源)=====
|
||||
export const aiGradeLog = mysqlTable(
|
||||
"ai_grade_log",
|
||||
{
|
||||
id: bigint("id", { mode: "number" }).primaryKey().autoincrement(),
|
||||
childUserId: bigint("child_user_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
paperId: varchar("paper_id", { length: 191 }).notNull(),
|
||||
questionIndex: int("question_index").notNull(),
|
||||
ossKey: varchar("oss_key", { length: 300 }).notNull(),
|
||||
// xuchao: ok=正常判分 blank=空白 blurry=模糊 printed=印刷体拒判 unrelated=无关内容 error=调用失败
|
||||
status: varchar("status", { length: 20 }).notNull(),
|
||||
earned: int("earned").notNull().default(0),
|
||||
points: int("points").notNull().default(0),
|
||||
verdict: text("verdict"), // JSON:逐评分点命中与点评
|
||||
mock: boolean("mock").notNull().default(false), // 演示判分标记(无 API key)
|
||||
model: varchar("model", { length: 60 }),
|
||||
ms: int("ms"), // 判分耗时
|
||||
createdAt: datetime("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_ai_grade_child_date").on(t.childUserId, t.createdAt),
|
||||
index("idx_ai_grade_paper_q").on(t.childUserId, t.paperId, t.questionIndex),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// xuchao: 解答题 AI 判分引擎——阿里云百炼 Qwen-VL,按评分点(rubric)部分给分
|
||||
// 无 DASHSCOPE_API_KEY 时进入演示判分(mock),供本地开发;生产必须配真 key
|
||||
|
||||
export type SolveStatus = "ok" | "blank" | "blurry" | "printed" | "unrelated" | "error";
|
||||
export type RubricItem = { point: string; score: number };
|
||||
export type RubricHit = { point: string; score: number; hit: boolean; reason: string };
|
||||
export type GradeVerdict = {
|
||||
status: SolveStatus;
|
||||
hits: RubricHit[];
|
||||
earned: number; // 由 hits 重算,不信任模型自报
|
||||
comment: string;
|
||||
mock: boolean;
|
||||
model: string;
|
||||
ms: number;
|
||||
};
|
||||
|
||||
const ENDPOINT = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
|
||||
const MODEL = process.env.AI_GRADE_MODEL || "qwen-vl-max";
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
// xuchao: 判分提示词——题干+参考解答+评分点+学生手写照片,只允许离散给分
|
||||
function buildPrompt(stem: string, points: number, reference: string, rubric: RubricItem[], hasFigure: boolean) {
|
||||
const rubricText = rubric.map((r, i) => `${i + 1}. ${r.point}(${r.score} 分)`).join("\n");
|
||||
return `你是一位经验丰富的数学阅卷老师,正在批改学生手写的解答题照片。
|
||||
|
||||
【题目】${stem}${hasFigure ? "\n(题目配有示意图,以题意为准)" : ""}
|
||||
【本题满分】${points} 分
|
||||
【参考解答】
|
||||
${reference}
|
||||
【评分点】
|
||||
${rubricText}
|
||||
|
||||
请按以下步骤评判:
|
||||
1. 判断照片状态,status 只能是以下之一:
|
||||
- ok:正常的手写解答
|
||||
- blank:空白或没有作答内容
|
||||
- blurry:字迹模糊无法辨认
|
||||
- printed:印刷体内容(非手写,拒绝批改)
|
||||
- unrelated:照片内容与本题无关
|
||||
2. 仅当 status=ok 时逐条评判评分点。学生方法可以与参考解答不同,只要该评分点的要求确实达成就 hit=true 并给该评分点满分,否则 hit=false 给 0 分。不允许给某个评分点部分分数。
|
||||
3. earned = 所有 hit=true 评分点分数之和,不得超过满分 ${points}。
|
||||
4. comment 给一句面向小学生的总体点评(指出亮点或主要问题)。
|
||||
|
||||
只输出一个 JSON 对象,不要输出任何其他文字:
|
||||
{"status":"ok","hits":[{"hit":true,"reason":"简短理由"}],"earned":数字,"comment":"点评"}
|
||||
hits 数组元素个数必须等于评分点个数(${rubric.length}),顺序与评分点一致。status 非 ok 时 hits 为空数组、earned 为 0。`;
|
||||
}
|
||||
|
||||
// xuchao: 演示判分(无 key):确定性模拟——最后一个评分点不得分,便于本地验证部分给分与错题链路
|
||||
function mockVerdict(stem: string, points: number, rubric: RubricItem[]): GradeVerdict {
|
||||
const hits: RubricHit[] = rubric.map((r, i) => ({
|
||||
point: r.point,
|
||||
score: r.score,
|
||||
hit: i < rubric.length - 1 || rubric.length === 1,
|
||||
reason: i < rubric.length - 1 || rubric.length === 1 ? "演示判分:达成" : "演示判分:未达成(模拟扣分)",
|
||||
}));
|
||||
const earned = hits.reduce((s, h) => s + (h.hit ? h.score : 0), 0);
|
||||
return {
|
||||
status: "ok",
|
||||
hits,
|
||||
earned: rubric.length === 1 ? points : Math.min(earned, points),
|
||||
comment: "这是演示判分(未配置百炼 API Key),结果仅用于流程测试。",
|
||||
mock: true,
|
||||
model: "mock",
|
||||
ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// xuchao: 从模型回复里稳健提取 JSON(容忍代码块包裹与多余文字)
|
||||
function extractJson(text: string): Record<string, unknown> | null {
|
||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
const body = fenced ? fenced[1] : text;
|
||||
const start = body.indexOf("{");
|
||||
const end = body.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) return null;
|
||||
try {
|
||||
return JSON.parse(body.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_STATUS = new Set(["ok", "blank", "blurry", "printed", "unrelated"]);
|
||||
|
||||
// xuchao: 校验并归一化模型判定;earned 一律按 hits 重算
|
||||
function normalizeVerdict(raw: Record<string, unknown>, rubric: RubricItem[], model: string, ms: number): GradeVerdict | null {
|
||||
const status = typeof raw.status === "string" && VALID_STATUS.has(raw.status) ? (raw.status as SolveStatus) : null;
|
||||
if (!status) return null;
|
||||
let hits: RubricHit[] = [];
|
||||
if (status === "ok") {
|
||||
const arr = Array.isArray(raw.hits) ? raw.hits : null;
|
||||
if (!arr || arr.length !== rubric.length) return null;
|
||||
hits = rubric.map((r, i) => {
|
||||
const h = (arr[i] ?? {}) as Record<string, unknown>;
|
||||
return { point: r.point, score: r.score, hit: h.hit === true, reason: typeof h.reason === "string" ? h.reason : "" };
|
||||
});
|
||||
}
|
||||
const earned = Math.min(
|
||||
points_cap(rubric),
|
||||
hits.reduce((s, h) => s + (h.hit ? h.score : 0), 0),
|
||||
);
|
||||
return {
|
||||
status,
|
||||
hits,
|
||||
earned,
|
||||
comment: typeof raw.comment === "string" ? raw.comment : "",
|
||||
mock: false,
|
||||
model,
|
||||
ms,
|
||||
};
|
||||
}
|
||||
|
||||
const points_cap = (rubric: RubricItem[]) => rubric.reduce((s, r) => s + r.score, 0);
|
||||
|
||||
// xuchao: 判分主入口;photoUrl 必须是公网可达地址(OSS 签名 URL)
|
||||
export async function gradeSolvePhoto(opts: {
|
||||
stem: string;
|
||||
points: number;
|
||||
rubric: RubricItem[];
|
||||
reference: string;
|
||||
photoUrl: string;
|
||||
figureUrl?: string;
|
||||
}): Promise<GradeVerdict> {
|
||||
const { stem, points, rubric, reference, photoUrl, figureUrl } = opts;
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY;
|
||||
if (!apiKey) return mockVerdict(stem, points, rubric);
|
||||
|
||||
const content: Record<string, unknown>[] = [
|
||||
{ type: "text", text: buildPrompt(stem, points, reference, rubric, !!figureUrl) },
|
||||
{ type: "image_url", image_url: { url: photoUrl } },
|
||||
];
|
||||
if (figureUrl) content.push({ type: "image_url", image_url: { url: figureUrl } });
|
||||
|
||||
let lastErr = "";
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
||||
const resp = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [{ role: "user", content }],
|
||||
temperature: 0.1,
|
||||
}),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!resp.ok) {
|
||||
lastErr = `HTTP ${resp.status}`;
|
||||
continue;
|
||||
}
|
||||
const data = (await resp.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const text = data.choices?.[0]?.message?.content ?? "";
|
||||
const raw = extractJson(text);
|
||||
if (!raw) {
|
||||
lastErr = "JSON 解析失败";
|
||||
continue;
|
||||
}
|
||||
const v = normalizeVerdict(raw, rubric, MODEL, Date.now() - t0);
|
||||
if (!v) {
|
||||
lastErr = "判定结构不符";
|
||||
continue;
|
||||
}
|
||||
return v;
|
||||
} catch (e) {
|
||||
lastErr = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
// xuchao: 两次均失败——返回 error 状态,前端提示重拍,不写入得分
|
||||
return { status: "error", hits: [], earned: 0, comment: `判分失败(${lastErr}),请重试`, mock: false, model: MODEL, ms: 0 };
|
||||
}
|
||||
+27
-2
@@ -1,12 +1,13 @@
|
||||
// xuchao: 考试板块——试卷查询与作答记录
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { examPapers, examRecords, taskInstances } from "@/db/schema";
|
||||
import { aiGradeLog, examPapers, examRecords, taskInstances } from "@/db/schema";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
import type { QuizQ } from "@/lib/quiz-parse";
|
||||
|
||||
// xuchao: 试卷题型标签(展示用)
|
||||
export const EXAM_TYPE_LABEL: Record<string, string> = {
|
||||
entrance: "入学考",
|
||||
monthly: "月考",
|
||||
unit: "单元卷",
|
||||
midterm: "期中",
|
||||
@@ -139,3 +140,27 @@ export async function recordExam(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// xuchao: 当日 AI 判分次数(限频用,含全部状态)
|
||||
export async function gradeCountToday(childId: number): Promise<number> {
|
||||
const midnight = new Date(`${todayStr()}T00:00:00`);
|
||||
const [r] = await db
|
||||
.select({ n: sql<number>`COUNT(*)` })
|
||||
.from(aiGradeLog)
|
||||
.where(and(eq(aiGradeLog.childUserId, childId), gte(aiGradeLog.createdAt, midnight)));
|
||||
return Number(r?.n ?? 0);
|
||||
}
|
||||
|
||||
// xuchao: 某孩子某卷每道解答题最近一次判分日志(交卷计分用)
|
||||
export async function latestSolveLogs(childId: number, paperId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(aiGradeLog)
|
||||
.where(and(eq(aiGradeLog.childUserId, childId), eq(aiGradeLog.paperId, paperId)))
|
||||
.orderBy(desc(aiGradeLog.id));
|
||||
const byQ = new Map<number, (typeof rows)[number]>();
|
||||
for (const r of rows) {
|
||||
if (!byQ.has(r.questionIndex)) byQ.set(r.questionIndex, r);
|
||||
}
|
||||
return byQ;
|
||||
}
|
||||
|
||||
+12
-9
@@ -63,19 +63,22 @@ export async function passedCount(childId: number): Promise<number> {
|
||||
|
||||
const norm = (s: unknown) => String(s ?? "").trim().toLowerCase();
|
||||
|
||||
// 服务端判分:五种题型 choice/judge/fill/order/match
|
||||
// 服务端判分:六种题型 choice/judge/fill/order/match/solve(solve 不在此处判,走 AI)
|
||||
export type AnswerValue = number | string | string[];
|
||||
|
||||
// xuchao: 单题客观判分(solve 返回 false,由 AI 判分链路单独处理)
|
||||
export function gradeOne(q: QuizQ, a: AnswerValue | null | undefined): boolean {
|
||||
if (q.type === "fill") return (q.answers ?? []).some((x) => norm(x) === norm(a));
|
||||
if (q.type === "order") return Array.isArray(a) && a.join(" ").trim() === (q.answer as string[]).join(" ").trim();
|
||||
if (q.type === "match") return Array.isArray(a) && (q.pairs ?? []).every((p, pi) => norm(a[pi]) === norm(p[1]));
|
||||
if (q.type === "solve") return false;
|
||||
return Number(a) === (q.answer as number);
|
||||
}
|
||||
|
||||
export function gradeAnswers(questions: QuizQ[], answers: AnswerValue[]) {
|
||||
let correct = 0;
|
||||
const detail = questions.map((q, i) => {
|
||||
const a = answers[i];
|
||||
let ok = false;
|
||||
if (q.type === "fill") ok = (q.answers ?? []).some((x) => norm(x) === norm(a));
|
||||
else if (q.type === "order")
|
||||
ok = Array.isArray(a) && a.join(" ").trim() === (q.answer as string[]).join(" ").trim();
|
||||
else if (q.type === "match")
|
||||
ok = Array.isArray(a) && (q.pairs ?? []).every((p, pi) => norm(a[pi]) === norm(p[1]));
|
||||
else ok = Number(a) === (q.answer as number);
|
||||
const ok = gradeOne(q, answers[i]);
|
||||
if (ok) correct++;
|
||||
return ok;
|
||||
});
|
||||
|
||||
@@ -36,3 +36,19 @@ export function viewUrl(key: string) {
|
||||
export function photoKey(childId: number, date: string) {
|
||||
return `photos/${childId}/${date}/${crypto.randomBytes(8).toString("hex")}.jpg`;
|
||||
}
|
||||
|
||||
// xuchao: 是否已配置 OSS(未配置时本地开发走 /api/dev-photo 落盘回退)
|
||||
export function ossConfigured() {
|
||||
return !!(BUCKET && ID && SECRET);
|
||||
}
|
||||
|
||||
// xuchao: 解答题作答照片键名:exam-solve/孩子ID/试卷/题号-随机.jpg
|
||||
export function solveKey(childId: number, paperId: string, questionIndex: number) {
|
||||
return `exam-solve/${childId}/${paperId}/q${questionIndex}-${crypto.randomBytes(8).toString("hex")}.jpg`;
|
||||
}
|
||||
|
||||
// xuchao: 作答照片展示地址:OSS 签名 GET;开发环境返回本地回退路由地址
|
||||
export function photoViewUrl(key: string) {
|
||||
if (ossConfigured()) return viewUrl(key);
|
||||
return `/api/dev-photo?key=${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
// 纯解析工具:不依赖数据库,可安全在客户端组件中使用
|
||||
|
||||
export type QuizQ = {
|
||||
type: "choice" | "fill" | "judge" | "order" | "match";
|
||||
type: "choice" | "fill" | "judge" | "order" | "match" | "solve";
|
||||
stem: string;
|
||||
options?: string[];
|
||||
answer?: number | string[];
|
||||
answers?: string[];
|
||||
words?: string[]; // order:打乱的词
|
||||
pairs?: [string, string][]; // match:左右配对
|
||||
// xuchao: solve 解答题(拍照 + AI 按评分点判分)
|
||||
figure?: string; // 题目配图路径(public/exam/<卷>/xxx)
|
||||
points?: number; // solve:本题满分
|
||||
reference?: string; // solve:完整参考解答
|
||||
rubric?: { point: string; score: number }[]; // solve:评分点,合计 = points
|
||||
// xuchao: 错题本里的解答题作答快照(照片 + 逐点判定),交卷入错题本时写入
|
||||
solveSnap?: { ossKey: string; earned: number; hits: { point: string; score: number; hit: boolean; reason: string }[] };
|
||||
explain: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AnswerValue } from "@/lib/grammar";
|
||||
|
||||
// 各类题型的"用户作答"与"标准答案"可读文本
|
||||
export function answerText(q: QuizQ, a: AnswerValue | null | undefined): string {
|
||||
if (q.type === "solve") return `拍照作答,得 ${q.solveSnap?.earned ?? 0} / ${q.points ?? 0} 分`;
|
||||
if (a == null) return "未作答";
|
||||
if (q.type === "fill") return String(a);
|
||||
if (q.type === "order") return Array.isArray(a) ? a.join(" ") : String(a);
|
||||
@@ -14,6 +15,7 @@ export function answerText(q: QuizQ, a: AnswerValue | null | undefined): string
|
||||
return opts[Number(a)] ?? String(a);
|
||||
}
|
||||
export function correctText(q: QuizQ): string {
|
||||
if (q.type === "solve") return q.reference ?? "";
|
||||
if (q.type === "fill") return (q.answers ?? []).join(" / ");
|
||||
if (q.type === "order") return (q.answer as string[]).join(" ");
|
||||
if (q.type === "match") return (q.pairs ?? []).map((p) => `${p[0]}→${p[1]}`).join("、");
|
||||
@@ -23,6 +25,7 @@ export function correctText(q: QuizQ): string {
|
||||
|
||||
// 错题入库(同一孩子同一来源同一题干去重)
|
||||
// sourceType:lesson/weekly=英语,mlesson/mweekly=数学,exam=考试卷
|
||||
// solveExtra:解答题的作答快照(照片 key + 逐评分点判定),并入题目 JSON 供错题本回看
|
||||
export async function recordWrong(
|
||||
childId: number,
|
||||
sourceType: "lesson" | "weekly" | "mlesson" | "mweekly" | "exam",
|
||||
@@ -30,7 +33,9 @@ export async function recordWrong(
|
||||
sourceTitle: string,
|
||||
q: QuizQ,
|
||||
user: AnswerValue | null | undefined,
|
||||
solveExtra?: NonNullable<QuizQ["solveSnap"]>,
|
||||
) {
|
||||
const stored: QuizQ = q.type === "solve" ? { ...q, solveSnap: solveExtra } : q;
|
||||
const rows = await db
|
||||
.select({ question: wrongBook.question })
|
||||
.from(wrongBook)
|
||||
@@ -48,9 +53,9 @@ export async function recordWrong(
|
||||
sourceType,
|
||||
sourceId,
|
||||
sourceTitle,
|
||||
question: JSON.stringify(q),
|
||||
userAnswer: answerText(q, user),
|
||||
correctAnswer: correctText(q),
|
||||
question: JSON.stringify(stored),
|
||||
userAnswer: answerText(stored, user),
|
||||
correctAnswer: correctText(stored),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user