- core/llm_utils: MiMo模型自动注入thinking=disabled参数,全局生效 - config: 新增LLM_DISABLE_THINKING配置项(默认true) - generator: 推理模型自适应max_tokens(1.5x)、429限流重试+指数退避、v2管线补题机制 - generator: analyze_document_for_exam新增max_total参数控制AI出题上限 - generator: validate_questions_schema兼容type和question_type字段 - grader: 主观题max_tokens从1000提升至2000、fuzzy_match增加编辑距离容错 - manager: results变量初始化防NameError、透传max_total参数 - api: /exam/generate-smart支持max_total请求参数
302 lines
12 KiB
Markdown
302 lines
12 KiB
Markdown
# 出题系统生成逻辑
|
||
|
||
> 源码:`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`)。
|