把一个大型题库 PDF 导入系统,自动抽取成结构化题库(题干 / 选项 / 答案 / 解析 / 题型),随后以随机抽题 + 错题优先 + 间隔重复的策略驱动刷题,即时判分、即时看解析、沉淀错题本与统计。
技术核心是半结构化题库的高质量结构化抽取管道:规则解析优先,DeepSeek LLM 兜底不规则块,全量入库前自动校验。零 GPU 依赖、无向量数据库、LLM 月成本预算 ¥50 以内。
完整技术方案(架构 / 里程碑 / 评测体系)见仓库根目录《QuizForge智能刷题系统-技术方案V2.docx》。
备考刷题的题库是 PDF,格式粗糙:答案内嵌题干括号((C ))、判断题行尾打勾(√)、选项分隔符三种混用(A./A./A、)、题号跳号、题干折行跨页。手工整理一份几十页的题库要几小时。QuizForge 把这个过程做成可度量、可回归的管道——本 README 中的所有数字都来自真实样本实测(一份高校期末题库:14 页 / 136 个题号块,已脱敏)。
# 环境:Python 3.12+,依赖 pdfplumber / pydantic / openai(界面需 streamlit)
pip install -e ".[ui,dev]"
# 1. 导入题库 PDF(规则优先;无 API key 时自动跳过 LLM 兜底)
cp .env.example .env # 填入 DEEPSEEK_API_KEY(可选)
python -m quizforge import /path/to/题库.pdf
# 2. 终端练习(即时判分 + 解析 + 错题本 + 间隔重复)
python -m quizforge banks
python -m quizforge practice <bank_id 或名称> -n 10
# 3. 图形界面(M2):题库管理 / 练习 / 错题本 / 学习统计
streamlit run app.py # 浏览器打开 http://localhost:8501
# 4. 测试与评测
pytest -q
python eval/run_eval.py import /path/to/题库.pdf对一份高校期末题库(14 页 / 136 个题号块,已脱敏)实测:
| 指标 | 纯规则(--no-llm) |
规则 + LLM 兜底 | 目标值(方案 7.1) |
|---|---|---|---|
| 切分完整率 | 95.6%(130/136) | 97.1%(132/136) | ≥ 98% |
| 规则直出率 | 100% | 99.2%(LLM 仅兜底 1 题) | ≥ 80% |
| 隔离区 | 6 条 | 4 条,全部可归因 | ≤ 5% 且可归因 |
| 题号缺口检测 | 正确识别「单选:缺 7」 | 同左 | — |
| 入库题型分布 | — | 单选 92 / 判断 25 / 填空 15 | — |
LLM 只兜底了 1 题:有一道古诗默写题,源文件里两个选项都标成了 B.(源文件笔误),规则无法确定选项边界,DeepSeek 修复标号后通过校验——这正是"规则管格式、LLM 管长尾"分工的理想形态。
隔离区 4 条(每条带 raw_block + fail_reason + page_ref,是规则库扩充的燃料):
| 页码 | 原因 | 处置 |
|---|---|---|
| p3 | 与第 6 题重复 | 去重拦截,符合预期 |
| p7 | 题干为空答案括号 ( ) |
源文件缺答案;不送 LLM(猜的答案不可信),转人工 |
| p14 ×2 | 判断题残片(无 √/×) | 源文件残缺,转人工 |
一条重要的诚实性设计:源文件本身没有答案线索的题块,不允许 LLM 兜底(
_has_answer_signal闸门)。兜底能修"格式坏但信息在"的题,不能猜"答案缺失"的题——否则会把不可信的猜测教给用户。这一条把 3 个「无答案线索」的题块挡在 LLM 之外(p7 空答案括号、p14 判断题残片 ×2),使完整率停在 97.1%,而不是靠模型猜测把数字刷高——这是刻意的取舍。
五个页面,全部复用 CLI 用的同一套引擎(抽题 / 判分 / 调度),界面层不含业务逻辑:
| 页面 | 能力 |
|---|---|
| 今日 | 落地页:今日待复习 / 错题待攻克 / 随机练习 三个快捷入口 + 今日已作答 / 连续学习 / 完成率 / 重做率 + 近 14 天曲线 |
| 练习 | 三种模式(随机 / 今日待复习 / 错题重练)→ 抽题 → 作答(选项即按钮,点选直接判分;多选与填空保留提交)→ 解析 → 答错一键生成带引用讲解 → 本轮小结 |
| 错题本 | 未攻克优先排序,页头「一键重练」直接开一轮只含错题的练习 |
| 学习统计 | 产品度量看板(见下)+ 近 14 天练习曲线 + 题型分布 |
| 题库管理 | 题库列表、上传 PDF 导入并展示导入报告、隔离区复核 |
streamlit run app.py # http://localhost:8501三个入口怎么来的:抽题引擎 pools.choose_question() 增加 restrict_ids 参数(只在给定题集内抽题,传空集合即"无题可抽",不会被当成"不限定"而抽全库)。
"今日待复习"限定 schedule.due_date <= 今天 的到期题,"错题重练"限定错题本题集——入口是数据视图,不是第二套抽题逻辑。
视觉:.streamlit/config.toml 定主题(靛蓝主色 + 浅灰分层),ui/app.py 注入自定义 CSS(卡片化题目区、圆角按钮与指标卡、紧凑排版)。
工程约束:数据库连接短开短关(Streamlit 每次交互重跑脚本,长连接会把未提交事务丢掉);练习状态存 st.session_state,跨 rerun 保持;快捷入口用 goto 中转页面跳转。
技术方案里的指标(切分完整率 / 规则直出率 / grounded_rate / whitelist_hit_rate)衡量的是系统质量;
metrics.py 衡量的则是产品是否真的被用起来 —— 两者不能互相替代。
| 指标 | 定义 | 为什么看它 |
|---|---|---|
| 练习完成率 | 答完的轮次 / 开始的轮次 | 判断练习流程是否劝退;中途离开也计入未完成 |
| 错题重做率 | 错题本里被再次作答过的题 / 错题总数 | 判断错题本是否真的被用起来(学习闭环是否成立) |
| 错题攻克率 | 已标记 mastered 的错题 / 错题总数 | 区分「回头看了」和「真的学会了」 |
| 总体正确率 | 答对次数 / 客观题作答次数 | 自评填空不计入分母,避免主观数据污染 |
| 题库覆盖率 | 作答过的题 / 题库总题数 | 衡量题库被消化到什么程度 |
| 连续学习天数 | 连续有作答记录的天数 | 学习习惯是否形成(今天没学不算断) |
这些数字不依赖任何模型调用,纯 SQL 聚合,可离线复现(口径由 tests/test_metrics.py 的 12 条单测锁死)。
为此在存储层新增
practice_sessions表(记录每轮练习的计划题数 / 实际作答数 / 正确数 / 状态)。完成率的分母就是它——没有这张表,「完成率」只能靠猜。
答错后自动生成带引用的知识点讲解:题干 → 查询构造 → 博查搜索 → 来源白名单重排 → 上下文组装 → DeepSeek 生成(强制引用编号)→ 讲解与网页快照落库。
python -m quizforge explain <题库> --seq 62 # 为指定题生成讲解
python -m quizforge practice <题库> -n 5 --explain # 练习时答错自动讲解
python eval/run_eval.py explain-eval <题库> -n 5 # 讲解质量评测实测指标(5 道题):
| 指标 | 值 | 说明 |
|---|---|---|
| grounded_rate | 1.0 | 全部讲解有检索支撑(无降级) |
| citation_coverage | 1.0 | 全部讲解标注了引用编号 |
| whitelist_hit_rate | 55.6% | tier1+tier2 来源占比(加查询引导前为 23.5%) |
两个诚实的设计取舍:
- 开放域的来源质量是硬约束:白名单命中率 55.6% 意味着近一半来源是问答站/小百科。这是"开放域 vs 闭域(课件)"的真实代价——闭域没有这个问题,但需要课件语料。数字如实记录,不做粉饰。
- 查询引导是有效的工程手段:query 追加"知识点 讲解 教材"后,白名单命中率 23.5% → 55.6%(对照实验)。这类"检索侧微调 + 指标验证"正是 RAG 工程的核心工作,已设为默认配置。
降级路径:搜索 API 不可用 / 无结果 / 生成失败 → 降级为 LLM 直讲,输出开头强制声明"以下内容来自模型知识,非教材原文",绝不假装有出处。网页快照落库保证引用可验证(不怕原页失效)。
六层,主链路不含任何检索组件:
| 层 | 职责 | 实现 |
|---|---|---|
| 接入层 | 练习 UI / 题库管理 / 统计 | Streamlit(ui/,M2 已实现) |
| 应用层 | 练习流程 / 判分 / 错题本 / 调度 | quiz/ engine + metrics.py |
| 抽取层 | 文本抽取 → 题型分节 → 切分 → 字段识别 → 校验隔离 | parsing/ + validate.py |
| 策略层 | 三池加权抽题 / Leitner 调度 / 防剧透 | strategy/ |
| 存储层 | SQLite 六表 + 题库 JSON 快照 | storage/ |
| 模型层 | LLM 兜底(DeepSeek,OpenAI 兼容)+ 讲解生成 | llm/ |
| 讲解层(M3) | 检索适配器 / 白名单 / 快照 / 带引用生成 | explain/ |
判分零 LLM、抽题不吐答案(防剧透契约)、LLM 输出必须过 Schema 校验——三条铁律见 docs/adr/。
这个产品的核心链路(结构化抽取 + 抽题 + 判分)不存在语义检索需求:抽题是结构化取样,判分是答案匹配。检索增强只在一个场景有明确职责——答错后从课件检索该知识点的讲解(M3 可选增强,届时引入 Chroma + BGE-M3 + 本地 CUDA)。具体论证见方案 V2「〇、版本说明」与 docs/adr/0007。
src/quizforge/
├── parsing/ # 抽取管道:pdf_extract → clean → sectioning → splitting → fields → pipeline
├── llm/ # DeepSeek 兜底客户端(Schema 校验 + 重试一次 + 隔离)
├── validate.py # 入库门禁七条校验
├── storage/ # SQLite 六表 + JSON 快照
├── strategy/ # Leitner 调度 + 三池抽题
├── explain/ # M3 讲解增强:搜索适配器 / 白名单 / 快照 / 带引用生成
├── quiz/ # 判分 + 练习引擎
├── metrics.py # M2 产品度量:完成率 / 错题重做率 / 攻克率 / 覆盖率 / 连续天数
├── ui/ # M2 Streamlit 界面(题库管理 / 练习 / 错题本 / 学习统计)
└── cli.py # import / banks / practice
app.py # Streamlit 启动入口(streamlit run app.py)
tests/ # 50 个单测(合成题库复刻真实版式特征;M3 全离线假源;UI 用 AppTest 冒烟)
eval/ # 评测入口 + 黄金标注集模板 + 基线报告
docs/adr/ # 9 条架构决策记录
仅处理个人合法持有的学习资料;data/(SQLite、快照、原始 PDF 产物)已 gitignore;公开仓库前对题库内容、文件名、截图统一脱敏。