# 出题系统生成逻辑 > 源码:`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` 请求体: ```json { "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()` 在出题前先检索文件的相关切片: 1. **构建语义 query**:根据 `question_types` 自动拼装检索词。例如需要填空题会追加"术语 公式 数值",需要主观题追加"流程 步骤 原则"。 2. **向量检索**:调用 `engine.search_knowledge()`,按文件名过滤(`source_filter`),支持文件名和完整路径两种格式,支持多 collection 按优先级检索。 3. **动态 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: 1. **精准检索**:`_retrieve_kp_chunks_v2(kp_name, section_chunks, top_k=5)` — 用知识点名称做关键词匹配,在该章节的切片中评分排序,取 top 5 最相关的切片。评分规则:知识点全文匹配 +100 分,关键词匹配 +10 分,内容长度适中加分。 2. **构建上下文**:`build_source_context(kp_chunks)` — 拼接切片内容,每个切片带 `[chunk_id:xxx | 第N页 章节]` 溯源标记。 3. **构造 Prompt**:`_build_prompt_for_kp()` — 指定核心知识点、难度、题型数量,要求"必须围绕该知识点出题、每道题不同角度、严禁非 JSON 内容"。附带 5 种题型的 JSON 格式示例。 4. **调用 LLM**:`_generate_with_retry()` — 最多重试 2 次。每次调用后 `safe_parse_questions()` 解析 JSON(支持直接解析、提取代码块、提取数组三种方式),`validate_questions_schema()` 校验(必须有 type/stem/answer,选择题必须有 options)。 5. **补充溯源**:`_enrich_with_source_trace()` — 给每道题附加 `source_trace`(文档名、chunk_id 列表、来源信息)。 ### Phase 4:质量校验 #### 4a. 去重 `_deduplicate_questions(questions, exclude_stems)` — 四层去重: 1. **题干前缀去重**:题干前 80 字相同 → 去掉。 2. **跨调用排除**(`_matches_exclude`):`exclude_stems` 中已有题目的前缀(≤30 字),新题干前 30 字如果以此为前缀 → 去掉。用 `startswith()` 匹配,解决排除题干短于 30 字时的长度不匹配问题。 3. **知识点+题型去重**:题干前 30 字 + 题型相同 → 去掉。 4. **跨调用题干去重**:`exclude_stems` 中已有题目的题干前 80 字预填入去重集合,精确匹配过滤。 #### 4b. 题型平衡 `_balance_question_types(questions, target_types)` — 按题型分组,每种题型按目标数量截取(多了截断)。 #### 4c. 补题 v1 的 `generate_questions_structured()` 有补题机制 `_makeup_questions()`:如果某题型数量不足,用前 5 个 chunks 重新出一轮补充。 v2 路径(`generate_questions_structured_v2`)同样支持补题:Phase 4.4 检查各题型是否达到目标数量,不足的题型调用 `_makeup_questions()` 补充,补题结果也经过去重处理。 --- ## 5. 各题型的 JSON 结构 ### 单选题 ```json { "type": "single_choice", "content": { "stem": "题干内容", "data": { "options": [{"key": "A", "content": "..."}, ...] }, "answer": "B", "explanation": "解析..." }, "referenced_chunk_ids": ["chunk_001"] } ``` ### 多选题 ```json { "type": "multiple_choice", "content": { "stem": "题干", "data": { "options": [...] }, "answer": ["A", "C"], "explanation": "解析..." } } ``` ### 判断题 ```json { "type": "true_false", "content": { "stem": "判断:某陈述", "data": {}, "answer": "对", "explanation": "解析..." } } ``` ### 填空题 ```json { "type": "fill_blank", "content": { "stem": "RAG的全称是___,核心在于___。", "data": { "blank_count": 2 }, "answer": [["检索增强生成"], ["外部知识库", "检索"]], "explanation": "解析..." } } ``` `answer` 是二维数组:每个空一个数组,数组内元素为该空的可接受答案(第一个为标准答案,其余为同义词)。 ### 主观题 ```json { "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`)。