- main.py: 去掉 emoji 避免 GBK 编码崩溃 - docs/curl测试手册.md: 更新模型名和测试日期 - docs/代码审查报告_2026-06-05.md: 删除过期报告 - docs/main分支独有功能说明.md: 新增 - docs/出题系统逻辑.md: 新增
11 KiB
出题系统生成逻辑
源码:
exam_pkg/主入口:manager.py → generate_questions_from_file()核心生成器:generator.py → generate_questions_structured_v2()
1. 整体架构
┌─────────────────────┐
│ API 入口 (api.py) │
│ /exam/generate │
│ /exam/generate-smart│
└──────────┬──────────┘
│ 异步任务 (task_id)
┌──────────▼──────────┐
│ manager.py │
│ generate_questions │
│ _from_file() │
└──────────┬──────────┘
│
┌────────────────────▼────────────────────┐
│ retrieve_file_chunks() │
│ 构建语义 query → 向量检索 → 文件切片列表 │
└────────────────────┬────────────────────┘
│
┌────────────────────▼────────────────────┐
│ generator.py: generate_questions_ │
│ structured_v2() │
│ │
│ Phase 1 文档结构分析(按章节分组) │
│ Phase 2 知识点规划(LLM 提取 + 去重) │
│ Phase 3 精准出题(AI分配 + 逐点检索) │
│ Phase 4 质量校验(去重 + 平衡 + 补题) │
└────────────────────┬────────────────────┘
│
┌──────────▼──────────┐
│ 返回题目列表 + 溯源 │
└─────────────────────┘
2. API 入口
2.1 标准出题 /exam/generate
请求体:
{
"file_path": "public_kb/产品手册.pdf",
"collection": "public_kb",
"question_types": {
"single_choice": 3,
"multiple_choice": 2,
"true_false": 2,
"fill_blank": 2,
"subjective": 1
},
"difficulty": 3,
"exclude_stems": ["已有题干1", "已有题干2"],
"options": { "max_source_chunks": 50 }
}
返回 task_id,通过 GET /tasks/{task_id} 轮询结果。
2.2 智能出题 /exam/generate-smart
不传 question_types,先调用 analyze_document_for_exam() 让 LLM 分析文档后自动推荐题型和数量,再走标准生成流程。
2.3 约束
| 约束项 | 值 |
|---|---|
| 总题数上限 | 20 道 |
| 难度范围 | 1-5 |
exclude_stems 上限 |
100 条 |
| 合法题型 | single_choice, multiple_choice, true_false, fill_blank, subjective |
3. 切片检索(Phase 0)
retrieve_file_chunks() 在出题前先检索文件的相关切片:
- 构建语义 query:根据
question_types自动拼装检索词。例如需要填空题会追加"术语 公式 数值",需要主观题追加"流程 步骤 原则"。 - 向量检索:调用
engine.search_knowledge(),按文件名过滤(source_filter),支持文件名和完整路径两种格式,支持多 collection 按优先级检索。 - 动态 top_k:
min(50, 总题数 × 3),确保切片数量足够覆盖所有题目。
4. 四阶段生成流水线
Phase 1:文档结构分析
group_chunks_by_section(chunks) — 将所有切片按 section 字段分组为 Dict[章节名, List[切片]]。
清理章节名中的 ** 等标记,空章节归入"未分类"。
Phase 2:知识点规划
对每个章节调用 _extract_knowledge_points(section, chunks, max_points=3):
- 长内容(≥100 字):调用 LLM 提取 3 个关键知识点(短短语,5-15 字),prompt 要求"适合出考试题、不重复不重叠"。
- 短内容(<100 字):直接清理后作为知识点名称,不调 LLM。
全局去重:所有知识点按 name 去重(seen_kp_names 集合),确保跨章节不重复。
每个知识点标记来源章节(kp['section']),供后续精准检索使用。
降级路径:如果所有章节都提取不出知识点,走 _generate_questions_fallback() — 把全部 chunks 拼成一个大 prompt 直接让 LLM 出题。
Phase 3:精准出题
3a. AI 分配题型
_ai_assign_question_types(knowledge_points, question_types) — 按章节轮询分配"哪个知识点出什么题型":
- 每种题型独立分配,确保题型覆盖。
- 轮询章节,优先从不同章节选知识点。
- 每个知识点最多出 1 道同题型题目。
输出 assignments 列表:[{"knowledge_point": "请假流程", "question_type": "single_choice", "section": "第三章"}, ...]
3b. 逐知识点出题
对每个 assignment:
- 精准检索:
_retrieve_kp_chunks_v2(kp_name, section_chunks, top_k=5)— 用知识点名称做关键词匹配,在该章节的切片中评分排序,取 top 5 最相关的切片。评分规则:知识点全文匹配 +100 分,关键词匹配 +10 分,内容长度适中加分。 - 构建上下文:
build_source_context(kp_chunks)— 拼接切片内容,每个切片带[chunk_id:xxx | 第N页 章节]溯源标记。 - 构造 Prompt:
_build_prompt_for_kp()— 指定核心知识点、难度、题型数量,要求"必须围绕该知识点出题、每道题不同角度、严禁非 JSON 内容"。附带 5 种题型的 JSON 格式示例。 - 调用 LLM:
_generate_with_retry()— 最多重试 2 次。每次调用后safe_parse_questions()解析 JSON(支持直接解析、提取代码块、提取数组三种方式),validate_questions_schema()校验(必须有 type/stem/answer,选择题必须有 options)。 - 补充溯源:
_enrich_with_source_trace()— 给每道题附加source_trace(文档名、chunk_id 列表、来源信息)。
Phase 4:质量校验
4a. 去重
_deduplicate_questions(questions, exclude_stems) — 三层去重:
- 题干前缀去重:题干前 80 字相同 → 去掉。
- 知识点+题型去重:题干前 30 字 + 题型相同 → 去掉。
- 跨调用去重:
exclude_stems中已有题目的题干前 80 字预填入去重集合,新生成的题目如果与之冲突也会被过滤。
4b. 题型平衡
_balance_question_types(questions, target_types) — 按题型分组,每种题型按目标数量截取(多了截断)。
4c. 补题(仅 v1 结构化路径)
v1 的 generate_questions_structured() 有补题机制 _makeup_questions():如果某题型数量不足,用前 5 个 chunks 重新出一轮补充。v2 路径依赖分配阶段的精确控制,不额外补题。
5. 各题型的 JSON 结构
单选题
{
"type": "single_choice",
"content": {
"stem": "题干内容",
"data": { "options": [{"key": "A", "content": "..."}, ...] },
"answer": "B",
"explanation": "解析..."
},
"referenced_chunk_ids": ["chunk_001"]
}
多选题
{
"type": "multiple_choice",
"content": {
"stem": "题干",
"data": { "options": [...] },
"answer": ["A", "C"],
"explanation": "解析..."
}
}
判断题
{
"type": "true_false",
"content": {
"stem": "判断:某陈述",
"data": {},
"answer": "对",
"explanation": "解析..."
}
}
填空题
{
"type": "fill_blank",
"content": {
"stem": "RAG的全称是___,核心在于___。",
"data": { "blank_count": 2 },
"answer": [["检索增强生成"], ["外部知识库", "检索"]],
"explanation": "解析..."
}
}
answer 是二维数组:每个空一个数组,数组内元素为该空的可接受答案(第一个为标准答案,其余为同义词)。
主观题
{
"type": "subjective",
"content": {
"stem": "请简述...",
"data": {
"scoring_points": [
{ "point": "要点1", "weight": 0.4 },
{ "point": "要点2", "weight": 0.3 },
{ "point": "要点3", "weight": 0.3 }
]
},
"answer": "参考范文...",
"explanation": "解析..."
}
}
6. 批题逻辑
批题入口:POST /exam/grade,同样是异步任务。
grader.py → grade_answers() 按题型分流:
| 题型 | 批阅方式 | 说明 |
|---|---|---|
single_choice / true_false |
本地判分 | 直接比对答案,不调 LLM |
multiple_choice |
本地判分 | set(student) == set(correct),顺序无关 |
fill_blank |
模糊匹配 | 逐空比对,支持同义词(answer 数组中的备选项),忽略空格和标点差异 |
subjective |
LLM 评分 | 将题目 stem + scoring_points + 参考答案 + 学生答案一起送给 LLM,按要点权重评分 |
并发控制:最多 3 路并发批阅(threading.Semaphore(3)),带 2 次重试。
7. 关键函数索引
| 函数 | 文件 | 作用 |
|---|---|---|
generate_questions_from_file |
manager.py | 出题总入口 |
analyze_file_for_exam |
manager.py | AI 智能分析(推荐题型) |
retrieve_file_chunks |
manager.py | 切片检索 |
generate_questions_structured_v2 |
generator.py | v2 四阶段生成主流程 |
generate_questions_structured |
generator.py | v1 生成主流程(含补题) |
group_chunks_by_section |
generator.py | 按章节分组 |
_extract_knowledge_points |
generator.py | LLM 知识点提取 |
_ai_assign_question_types |
generator.py | AI 题型分配 |
_retrieve_kp_chunks_v2 |
generator.py | 知识点精准检索 |
_build_prompt_for_kp |
generator.py | 构造出题 Prompt |
_generate_with_retry |
generator.py | 带重试的 LLM 调用 |
safe_parse_questions |
generator.py | JSON 安全解析 |
validate_questions_schema |
generator.py | 题目 Schema 校验 |
_enrich_with_source_trace |
generator.py | 补充溯源信息 |
_deduplicate_questions |
generator.py | 三层去重 |
_balance_question_types |
generator.py | 题型数量平衡 |
_generate_questions_fallback |
generator.py | 降级路径(无知识点时) |
_makeup_questions |
generator.py | v1 补题机制 |
grade_answers |
grader.py | 批题总入口 |
grade_objective |
grader.py | 客观题本地批阅 |
grade_fill_blank |
grader.py | 填空题模糊匹配 |
8. LLM 调用统计
一次标准出题(10 题、5 章节)的 LLM 调用次数估算:
| 阶段 | 调用次数 | 说明 |
|---|---|---|
| 知识点提取 | ~5 次 | 每章节 1 次 |
| 题型分配 | 0 次 | 本地算法分配 |
| 出题 | ~10 次 | 每知识点 1 次(含重试) |
| Schema 校验 | 0 次 | 本地逻辑 |
| 去重 / 平衡 | 0 次 | 本地逻辑 |
| 合计 | ~15 次 |
智能出题额外增加 1 次 LLM 调用(analyze_document_for_exam)。