diff --git a/.gitignore b/.gitignore index 96ecd4d..f266dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ materials/ # playwright-mcp debug snapshots .playwright-mcp/ + +# 本地开发解答题照片回退目录 +.dev-uploads/ diff --git a/docs/解答题判分方案.md b/docs/解答题判分方案.md new file mode 100644 index 0000000..ae27149 --- /dev/null +++ b/docs/解答题判分方案.md @@ -0,0 +1,73 @@ +# 解答题拍照判分方案(设计决策记录) + +> 2026-08-06 用户授权:全部按推荐方案落地,无需逐项确认。本文档记录已确认的全部决策。 + +## 一、目标 + +考试板块支持**数学解答题**(计算/解方程/应用题):孩子在纸上写完整解答过程,手机拍照上传,AI 视觉模型按评分点部分给分。首期试点一张**初一入学考(小升初分班考)卷**,现有 5 张月考卷不动。 + +## 二、已确认决策(逐条) + +| # | 决策点 | 结论 | +| --- | --- | --- | +| 1 | 题型范围 | 困难档:解答题**过程判分**(不是只判最终答案) | +| 2 | 作答输入 | **纸上手写 + 拍照**(不做屏幕手写、不做分步结构化输入) | +| 3 | AI 模型 | **阿里云百炼 Qwen-VL**(qwen-vl-max),用户 Tokenplan 计费;经 OpenAI 兼容端点调用 | +| 4 | 答题流程 | **混合模式**:客观题(选择/判断/填空)App 内作答;**解答题逐题拍照**,不做整页拍 | +| 5 | 判分规则 | **部分给分 + 评分点(rubric)锚定**:AI 逐点评判,只能返回离散分数,不给自由打分权 | +| 6 | 题目配图 | 原卷裁剪为主、SVG 兜底,存 `public/exam/<卷id>/`;几何题判分时**原图+孩子照片一起喂模型**。本期入学卷无图题 | +| 7 | 内容生产 | **零人工**:抓卷/转录 → **双模型背靠背独立验算,不一致即丢弃**;本期因网络策略无法直抓教育站,内容改为**本地 Obsidian 数学素材 + 经典分班考题型按真实卷格式组卷**,已在试卷 summary 标注 | +| 8 | 计分 | 分值跟随原卷;总分 = 客观题 + 解答题部分得分;**达标线保持 80%**;exam_record 存每题照片 URL + 评分点命中明细 | +| 9 | 错题本 | v1 **只存不重做**:存题目 + 当时照片 + AI 评判 + 参考解答;保留手动"已掌握";重做重判放二期 | +| 10 | 质量控制 | AI 返回状态:`ok / blank(空白) / blurry(模糊) / printed(印刷体拒判) / unrelated(无关内容)`;非手写拒判;**每人每天限 30 次判分**;客户端压缩照片 ≤2MB | +| 11 | 时效 | 同步等待 + "AI 判分中"动画;失败自动重试 1 次,再失败允许单题重拍重交,不阻塞其它题 | +| 12 | 范围 | 解答题只进考试板块;课时练习/周测暂不加 | + +## 三、数据模型 + +### QuizQ 扩展(`src/lib/quiz-parse.ts`) + +```ts +{ + type: "solve", + stem: string, // 题干 + figure?: string, // 配图路径(可选,本期不用) + points: number, // 本题满分(分值跟随原卷) + reference: string, // 完整参考解答(判后展示) + rubric: { point: string; score: number }[], // 评分点,合计 = points + explain: string // 讲解 +} +``` + +### 新表 `ai_grade_log`(迁移 0004) + +判分审计 + 限频依据:child_user_id、paper_id、question_index、oss_key、status、earned、points、verdict(JSON:逐点命中+点评)、mock(是否演示判分)、created_at。 + +### exam_record.answers + +解答题条目:`{ type:"solve", qIndex, ossKey, earned, hits:[{point,score,hit,reason}] }`;客观题保持原样。**成绩以服务端 ai_grade_log 为准,不信任客户端上报的分数**(防篡改)。 + +## 四、判分链路 + +``` +孩子拍照 → 客户端压缩(≤2MB,canvas) → 服务端签发 OSS 直传 URL → PUT OSS +→ gradeSolve action:校验会话/限频(30/天) → 调百炼 Qwen-VL + prompt = 题干 + (原图) + 参考解答 + 评分点 + 孩子照片 +→ 结构化 JSON:{status, hits:[{hit,reason}], earned, comment} +→ 写 ai_grade_log → 返回前端即时显示逐点得分 +→ 全卷提交 takeExam:客观题即时判 + 解答题取 ai_grade_log 最近一次 ok 判定 +→ 总分百分制,≥80 达标,错题(含照片)进 wrong_book,达标联动数学打卡 +``` + +- **无 DASHSCOPE_API_KEY 时进入演示判分(mock)**:返回确定性模拟判定并标记 `mock=true`,供本地开发/测试;生产必须在服务器 .env 配置真 key。 +- OSS 未配置时(本地开发)走 `/api/dev-photo` 本地落盘回退,不影响生产。 + +## 五、内容来源说明(本期入学卷) + +本 session 网络策略拦截了对教育网站(学科网/组卷网/瑞文/51test 等)的直接抓取,无法获取原始真实卷页面。本期入学卷改为:**按真实分班考的卷面结构(填空+选择+计算+应用,满分 100)** 组卷,题目取自本地 Obsidian 数学素材库的经典题型(鸡兔同笼、工程、分数百分数、行程等,均带验算)。`sourceUrl` 置空并在 `summary` 注明。待网络恢复后可用同流程替换为真实卷转录。 + +## 六、上线前待办(需用户) + +1. 百炼控制台开通/确认 Tokenplan 覆盖 `qwen-vl-max`,取 API Key; +2. 服务器 `/opt/services/superjump/.env` 增加 `DASHSCOPE_API_KEY=...`,重建容器; +3. 跑 `npm run db:seed-exam` 灌入新卷(容器重建时自动 migrate)。 diff --git a/drizzle/0004_ai-grade-log.sql b/drizzle/0004_ai-grade-log.sql new file mode 100644 index 0000000..e3ae406 --- /dev/null +++ b/drizzle/0004_ai-grade-log.sql @@ -0,0 +1,22 @@ +CREATE TABLE `ai_grade_log` ( + `id` bigint AUTO_INCREMENT NOT NULL, + `child_user_id` bigint NOT NULL, + `paper_id` varchar(191) NOT NULL, + `question_index` int NOT NULL, + `oss_key` varchar(300) NOT NULL, + `status` varchar(20) NOT NULL, + `earned` int NOT NULL DEFAULT 0, + `points` int NOT NULL DEFAULT 0, + `verdict` text, + `mock` boolean NOT NULL DEFAULT false, + `model` varchar(60), + `ms` int, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `ai_grade_log_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `ai_grade_log` ADD CONSTRAINT `ai_grade_log_child_user_id_user_id_fk` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX `idx_ai_grade_child_date` ON `ai_grade_log` (`child_user_id`,`created_at`); +--> statement-breakpoint +CREATE INDEX `idx_ai_grade_paper_q` ON `ai_grade_log` (`child_user_id`,`paper_id`,`question_index`); diff --git a/drizzle/0005_task-template-mode.sql b/drizzle/0005_task-template-mode.sql new file mode 100644 index 0000000..beea504 --- /dev/null +++ b/drizzle/0005_task-template-mode.sql @@ -0,0 +1,11 @@ +-- xuchao: 补齐 task_template 的模板生效方式字段。生产库曾手工 ALTER 过这三列, +-- 故用 information_schema 判断实现幂等:已存在则跳过,保证生产与全新库都能安全执行。 +SET @has_mode = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'task_template' AND column_name = 'mode'); +--> statement-breakpoint +SET @ddl = IF(@has_mode = 0, 'ALTER TABLE `task_template` ADD COLUMN `mode` varchar(10) NOT NULL DEFAULT ''weekly'', ADD COLUMN `date_start` date, ADD COLUMN `date_end` date', 'SELECT 1'); +--> statement-breakpoint +PREPARE stmt FROM @ddl; +--> statement-breakpoint +EXECUTE stmt; +--> statement-breakpoint +DEALLOCATE PREPARE stmt; diff --git a/drizzle/0006_fill-missing-tables.sql b/drizzle/0006_fill-missing-tables.sql new file mode 100644 index 0000000..99b1410 --- /dev/null +++ b/drizzle/0006_fill-missing-tables.sql @@ -0,0 +1,118 @@ +-- xuchao: 修复历史迁移缺口。早期这些表是用 drizzle push/手工 DDL 建进生产的, +-- 仓库里一直没有对应迁移,导致全新数据库建不出这些表。本迁移全部幂等: +-- 表已存在则 CREATE TABLE IF NOT EXISTS 跳过,生产库重复执行无副作用。 +CREATE TABLE IF NOT EXISTS `grammar_point` ( + `id` bigint AUTO_INCREMENT NOT NULL, + `point_id` varchar(191) NOT NULL, + `level` varchar(20) NOT NULL, + `level_name` varchar(50) NOT NULL, + `title` varchar(200) NOT NULL, + `difficulty` int NOT NULL DEFAULT 1, + `summary` text, + `prereq` text, + `md` mediumtext NOT NULL, + `seq` int NOT NULL, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `grammar_point_id` PRIMARY KEY(`id`), + CONSTRAINT `uq_grammar_point_id` UNIQUE(`point_id`) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `weekly_test` ( + `id` bigint AUTO_INCREMENT NOT NULL, + `child_user_id` bigint NOT NULL, + `week_key` varchar(12) NOT NULL, + `score` int, + `correct` int, + `total` int, + `passed` boolean NOT NULL DEFAULT false, + `questions` mediumtext, + `taken_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `weekly_test_id` PRIMARY KEY(`id`), + CONSTRAINT `uq_weekly_child_week` UNIQUE(`child_user_id`,`week_key`), + CONSTRAINT `fk_wt_child` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `word_progress` ( + `id` bigint AUTO_INCREMENT NOT NULL, + `child_user_id` bigint NOT NULL, + `day` int NOT NULL, + `learned` mediumtext, + `score` int, + `passed` boolean NOT NULL DEFAULT false, + `completed_at` datetime, + CONSTRAINT `word_progress_id` PRIMARY KEY(`id`), + CONSTRAINT `uq_word_child_day` UNIQUE(`child_user_id`,`day`), + CONSTRAINT `fk_wp_child` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `word_recording` ( + `id` bigint AUTO_INCREMENT NOT NULL, + `child_user_id` bigint NOT NULL, + `word` varchar(60) NOT NULL, + `day` int, + `oss_key` varchar(300) NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `word_recording_id` PRIMARY KEY(`id`), + KEY `idx_rec_child` (`child_user_id`), + CONSTRAINT `fk_wr_child` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `wrong_book` ( + `id` bigint AUTO_INCREMENT NOT NULL, + `child_user_id` bigint NOT NULL, + `source_type` varchar(10) NOT NULL, + `source_id` varchar(60) NOT NULL, + `source_title` varchar(200), + `question` mediumtext NOT NULL, + `user_answer` mediumtext, + `correct_answer` mediumtext, + `mastered` boolean NOT NULL DEFAULT false, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT `wrong_book_id` PRIMARY KEY(`id`), + KEY `idx_wrong_child` (`child_user_id`,`mastered`), + CONSTRAINT `fk_wb_child` FOREIGN KEY (`child_user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action +); +--> statement-breakpoint +-- grammar_progress:0002 误用 seq 列建表,生产实际是 point_id。仅在缺 point_id 时转换。 +-- 顺序关键:先加 point_id 与 uq_grammar_child_point(child_user_id 为最左前缀,可承接外键), +-- 之后才能安全删除被外键依赖的旧索引 uq_grammar_child_seq,全程不动外键本身。 +SET @gp_has_point = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'grammar_progress' AND column_name = 'point_id'); +--> statement-breakpoint +SET @ddl = IF(@gp_has_point = 0, 'ALTER TABLE `grammar_progress` ADD COLUMN `point_id` varchar(191) NOT NULL', 'SELECT 1'); +--> statement-breakpoint +PREPARE stmt FROM @ddl; +--> statement-breakpoint +EXECUTE stmt; +--> statement-breakpoint +DEALLOCATE PREPARE stmt; +--> statement-breakpoint +SET @gp_has_point_idx = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'grammar_progress' AND index_name = 'uq_grammar_child_point'); +--> statement-breakpoint +SET @ddl = IF(@gp_has_point_idx = 0, 'ALTER TABLE `grammar_progress` ADD CONSTRAINT `uq_grammar_child_point` UNIQUE (`child_user_id`,`point_id`)', 'SELECT 1'); +--> statement-breakpoint +PREPARE stmt FROM @ddl; +--> statement-breakpoint +EXECUTE stmt; +--> statement-breakpoint +DEALLOCATE PREPARE stmt; +--> statement-breakpoint +SET @gp_has_seq_idx = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'grammar_progress' AND index_name = 'uq_grammar_child_seq'); +--> statement-breakpoint +SET @ddl = IF(@gp_has_seq_idx > 0, 'ALTER TABLE `grammar_progress` DROP INDEX `uq_grammar_child_seq`', 'SELECT 1'); +--> statement-breakpoint +PREPARE stmt FROM @ddl; +--> statement-breakpoint +EXECUTE stmt; +--> statement-breakpoint +DEALLOCATE PREPARE stmt; +--> statement-breakpoint +SET @gp_has_seq = (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'grammar_progress' AND column_name = 'seq'); +--> statement-breakpoint +SET @ddl = IF(@gp_has_seq > 0, 'ALTER TABLE `grammar_progress` DROP COLUMN `seq`', 'SELECT 1'); +--> statement-breakpoint +PREPARE stmt FROM @ddl; +--> statement-breakpoint +EXECUTE stmt; +--> statement-breakpoint +DEALLOCATE PREPARE stmt; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5f8a143..efa47ec 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,27 @@ "when": 1785772800000, "tag": "0003_math-and-exam", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1785859200000, + "tag": "0004_ai-grade-log", + "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1786060800000, + "tag": "0005_task-template-mode", + "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1786064400000, + "tag": "0006_fill-missing-tables", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs index bf430ef..a317d0c 100644 --- a/scripts/migrate.mjs +++ b/scripts/migrate.mjs @@ -12,7 +12,8 @@ if (!url) { const connection = await mysql.createConnection(url); try { - await migrate(drizzle(connection), { migrationsFolder: "/app/drizzle" }); + // xuchao: 容器内固定 /app/drizzle;本地测试可用 MIGRATIONS_DIR 覆盖 + await migrate(drizzle(connection), { migrationsFolder: process.env.MIGRATIONS_DIR || "/app/drizzle" }); console.log("数据库迁移完成"); } finally { await connection.end(); diff --git a/src/app/api/dev-photo/route.ts b/src/app/api/dev-photo/route.ts new file mode 100644 index 0000000..07035c0 --- /dev/null +++ b/src/app/api/dev-photo/route.ts @@ -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" } }); +} diff --git a/src/app/exam/actions.ts b/src/app/exam/actions.ts index 91f6b3f..6bebb7d 100644 --- a/src/app/exam/actions.ts +++ b/src/app/exam/actions.ts @@ -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 { +// 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 { 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; } diff --git a/src/app/exam/page.tsx b/src/app/exam/page.tsx index e121611..2545419 100644 --- a/src/app/exam/page.tsx +++ b/src/app/exam/page.tsx @@ -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 = { + 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(); diff --git a/src/app/learn/quiz-block.tsx b/src/app/learn/quiz-block.tsx index 541c704..820020a 100644 --- a/src/app/learn/quiz-block.tsx +++ b/src/app/learn/quiz-block.tsx @@ -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(questions.map(() => null)); + const [answers, setAnswers] = useState(questions.map(() => null)); const [result, setResult] = useState | 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({

{qi + 1}. {q.stem} + {q.type === "solve" && {q.points} 分 · 拍照作答}

{q.type === "fill" && ( @@ -117,11 +134,21 @@ export function QuizBlock({
)} - {q.type === "order" && set(qi, v)} />} + {q.type === "order" && set(qi, v)} />} - {q.type === "match" && set(qi, v)} />} + {q.type === "match" && set(qi, v)} />} - {result && ( + {q.type === "solve" && ( + set(qi, v)} + /> + )} + + {result && q.type !== "solve" && (

{q.type === "fill" && ( 参考:{(q.answers ?? []).join(" / ")} @@ -137,7 +164,14 @@ export function QuizBlock({ 解说:{q.explain}

)} - {result && ok === false &&

这题答错了,看看上面的参考与解说。

} + {result && q.type === "solve" && ( +
+

参考解答:

+

{q.reference}

+

解说:{q.explain}

+
+ )} + {result && ok === false &&

这题没拿满分,看看上面的参考与解说。

} ); })} @@ -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(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 ( +
+ { + void onPick(e.target.files?.[0]); + e.target.value = ""; + }} + /> + +

写清每一步过程,按评分点给分;请横拍、光线充足、字迹清楚。

+
+ ); + } + + const dataUrl = st.phase === "preview" || st.phase === "grading" || st.phase === "graded" ? st.dataUrl : ""; + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 作答照片 + + {st.phase === "preview" && !disabled && ( +
+ + + { + void onPick(e.target.files?.[0]); + e.target.value = ""; + }} + /> +
+ )} + + {st.phase === "grading" && ( +

+ {busy || "AI 判分中…"} +

+ )} + + {st.phase === "graded" && ( + <> + + {st.verdict.status !== "ok" && !disabled && ( + + )} + + )} + + {err &&

{err}

} +
+ ); +} + +// xuchao: 判分结果展示——逐评分点命中情况 + 得分 + AI 点评 +function SolveVerdict({ verdict }: { verdict: GradeVerdict }) { + if (verdict.status !== "ok") { + const tip: Record = { + blank: "照片里没有作答内容,请写完再拍。", + blurry: "字迹太模糊啦,重新拍一张清楚点的。", + printed: "只能批改手写解答,请把过程写在纸上再拍。", + unrelated: "照片内容和这道题无关,请拍这道题的解答。", + error: "AI 判分出了点问题,请重试。", + }; + return ( +
+ {tip[verdict.status] ?? "请重新拍照"} {verdict.comment && ({verdict.comment})} +
+ ); + } + const total = verdict.hits.reduce((s, h) => s + h.score, 0); + return ( +
+

+ AI 判分:{verdict.earned} / {total} 分 +

+
    + {verdict.hits.map((h, i) => ( +
  • + {h.hit ? ( + + ) : ( + + )} + + ({h.score}分){h.point} + {h.reason && —— {h.reason}} + +
  • + ))} +
+ {verdict.comment &&

💬 {verdict.comment}

} + {verdict.mock &&

演示判分(未配置 AI Key)

} +
+ ); +} + // 连词成句:点词入槽,再点槽里的词可移回 function OrderInput({ q, diff --git a/src/app/wrongbook/page.tsx b/src/app/wrongbook/page.tsx index 353b2ad..98ad8f4 100644 --- a/src/app/wrongbook/page.tsx +++ b/src/app/wrongbook/page.tsx @@ -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 ( ); })} diff --git a/src/app/wrongbook/wrong-row.tsx b/src/app/wrongbook/wrong-row.tsx index 6acd3e4..2e341f8 100644 --- a/src/app/wrongbook/wrong-row.tsx +++ b/src/app/wrongbook/wrong-row.tsx @@ -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 (
-

{q.stem}

+

+ {q.stem} + {isSolve && 解答题} +

{sourceTitle ?? "检测"}
+ + {isSolve && photoUrl && ( + // xuchao: 当时的作答照片(签名地址 5 分钟有效,页面刷新即重签) +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 作答照片 +
+ )} + + {isSolve && (q.solveSnap?.hits?.length ?? 0) > 0 && ( +
    + {q.solveSnap!.hits.map((h, i) => ( +
  • + {h.hit ? ( + + ) : ( + + )} + + ({h.score}分){h.point} + +
  • + ))} +
+ )} +

你的答案:{userAnswer ?? "未作答"}

-

正确答案:{correctAnswer ?? ""}

+

+ {isSolve ? "参考解答:" : "正确答案:"} + {correctAnswer ?? ""} +

💡 {q.explain}

diff --git a/src/content/exam/g7-s1-00.json b/src/content/exam/g7-s1-00.json new file mode 100644 index 0000000..f453c04 --- /dev/null +++ b/src/content/exam/g7-s1-00.json @@ -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 千米。" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 71ae14a..080c5e7 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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), + ], +); diff --git a/src/lib/ai-grade.ts b/src/lib/ai-grade.ts new file mode 100644 index 0000000..9bed21a --- /dev/null +++ b/src/lib/ai-grade.ts @@ -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 | 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, 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; + 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 { + 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[] = [ + { 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 }; +} diff --git a/src/lib/exam.ts b/src/lib/exam.ts index 8900a67..7ceeae3 100644 --- a/src/lib/exam.ts +++ b/src/lib/exam.ts @@ -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 = { + entrance: "入学考", monthly: "月考", unit: "单元卷", midterm: "期中", @@ -139,3 +140,27 @@ export async function recordExam( }); } } + +// xuchao: 当日 AI 判分次数(限频用,含全部状态) +export async function gradeCountToday(childId: number): Promise { + const midnight = new Date(`${todayStr()}T00:00:00`); + const [r] = await db + .select({ n: sql`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(); + for (const r of rows) { + if (!byQ.has(r.questionIndex)) byQ.set(r.questionIndex, r); + } + return byQ; +} diff --git a/src/lib/grammar.ts b/src/lib/grammar.ts index b59927f..a456d39 100644 --- a/src/lib/grammar.ts +++ b/src/lib/grammar.ts @@ -63,19 +63,22 @@ export async function passedCount(childId: number): Promise { 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; }); diff --git a/src/lib/oss.ts b/src/lib/oss.ts index 7da9b85..8fedb95 100644 --- a/src/lib/oss.ts +++ b/src/lib/oss.ts @@ -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)}`; +} diff --git a/src/lib/quiz-parse.ts b/src/lib/quiz-parse.ts index 8eee42b..d35c5bf 100644 --- a/src/lib/quiz-parse.ts +++ b/src/lib/quiz-parse.ts @@ -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; }; diff --git a/src/lib/wrongbook.ts b/src/lib/wrongbook.ts index 1d78656..252044e 100644 --- a/src/lib/wrongbook.ts +++ b/src/lib/wrongbook.ts @@ -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, ) { + 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), }); }