Files
rag/docs/出题批卷系统设计.md
lacerate551 a3aa6cbcf3 docs: 完善出题批卷系统设计文档,补充服务器实测验证备注
- generate-smart 的 requested_types 可能包含值为0的题型(AI分析结果)
- actual_types 仅包含实际生成数量大于0的题型
- 文档已通过生产服务器三端点实测验证
2026-06-22 19:23:52 +08:00

768 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 出题批卷系统设计
> **文档类型**: 系统设计文档 & 后端对接手册
> **创建日期**: 2026-04-10
> **最后更新**: 2026-06-22
> **版本**: 3.0
> **状态**: 已实施
---
## 一、系统概述
### 1.1 定位
出题批卷系统是 RAG 知识库服务的扩展模块(`exam_pkg`负责基于知识库文档自动生成题目和自动批阅。RAG 服务只负责出题和批题的无状态计算,题目的存储、审核、状态管理由后端服务负责。
### 1.2 职责边界
| 职责 | RAG 服务 | 后端服务 |
|------|----------|----------|
| 题目生成 | 基于文档生成题目 + 溯源 | 审核入库 + 状态管理 |
| 题目批阅 | 逐题评分 + 反馈 | 汇总报告 + 成绩存储 |
| 题目存储 | 无状态,不存储 | MySQL 持久化 |
### 1.3 模块结构
```
exam_pkg/
├── generator.py # 出题引擎v2管线章节分组→知识点提取→精准出题→去重补题
├── grader.py # 批阅引擎(客观题精确匹配 + 填空题模糊匹配 + 主观题LLM评分
├── manager.py # 编排层(检索切片→调用引擎→组装返回)
├── api.py # Flask Blueprintexam_bp, url_prefix=/exam
└── local_db.py # 本地题库缓存SQLite辅助功能
```
### 1.4 服务信息
| 项目 | 值 |
|------|-----|
| 服务地址 | `http://<host>:5001` |
| URL前缀 | `/exam` |
| 健康检查 | `GET /exam/health` |
| 运行模式 | gunicorn + gthread (2 workers) |
---
## 二、统一响应格式
所有接口返回统一 JSON 信封:
### 2.1 成功响应
```json
{
"success": true,
"status": "success",
"status_code": 2020,
"message": "出题成功",
"data": { ... }
}
```
### 2.2 失败响应
```json
{
"success": false,
"status": "failed",
"error_code": "MISSING_PARAMS",
"status_code": 4000,
"message": "缺少 file_path 或 collection 参数"
}
```
### 2.3 业务状态码
| 状态码 | 常量名 | 说明 |
|--------|--------|------|
| 2020 | EXAM_SUCCESS | 出题成功 |
| 2021 | GRADE_SUCCESS | 批阅完成 |
| 4000 | BAD_REQUEST | 请求参数错误 |
| 4001 | UNAUTHORIZED | 未授权 |
| 4002 | FORBIDDEN | 权限不足 |
| 5020 | EXAM_ERROR | 出题失败 |
| 5021 | GRADE_ERROR | 批阅失败 |
---
## 三、API 接口
### 3.1 POST /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"],
"request_id": "uuid-optional",
"options": {
"include_explanation": true,
"max_source_chunks": 50
}
}
```
#### 请求参数说明
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `file_path` | string | 是 | — | 文件路径(向量库内相对路径) |
| `collection` | string | 是 | — | 向量库名称 |
| `question_types` | object | 是 | — | 题型及数量,见下方合法值 |
| `difficulty` | int | 否 | 3 | 难度等级 1-5 |
| `exclude_stems` | string[] | 否 | — | 排除已有题干跨调用去重最多100条 |
| `request_id` | string | 否 | — | 幂等性标识,原样返回 |
| `options.include_explanation` | bool | 否 | true | 是否生成答案解析 |
| `options.max_source_chunks` | int | 否 | 50 | 最大检索切片数 |
**question_types 合法键值**`single_choice``multiple_choice``true_false``fill_blank``subjective`。值为非负整数,总题数上限 **20**
#### 成功响应
```json
{
"success": true,
"status": "success",
"status_code": 2020,
"message": "出题成功",
"data": {
"success": true,
"request_id": "uuid",
"questions": [ ... ],
"total": 10,
"requested_types": {"single_choice": 3, "fill_blank": 2},
"actual_types": {"single_choice": 3, "fill_blank": 2},
"source_chunks_used": 15,
"warnings": ["fill_blank: 请求 3 道,实际生成 2 道"]
}
}
```
**data 字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `success` | bool | 是否成功 |
| `request_id` | string/null | 请求标识原样返回 |
| `questions` | array | 题目列表,结构见第四章 |
| `total` | int | 实际生成题数 |
| `requested_types` | object | 请求的题型和数量 |
| `actual_types` | object | 实际生成的题型和数量 |
| `source_chunks_used` | int | 使用的文档切片数 |
| `warnings` | string[] | 警告信息(可选,某题型实际生成少于请求时出现) |
---
### 3.2 POST /exam/generate-smart — AI 智能出题
AI 自动分析文档内容,决定题型和数量后生成题目。与 `/exam/generate` 的区别是**不需要传 question_types**。
#### 请求体
```json
{
"file_path": "public_kb/产品手册.pdf",
"collection": "public_kb",
"difficulty": 3,
"max_total": 20,
"exclude_stems": ["已有题目的题干1"],
"request_id": "uuid-optional",
"options": {}
}
```
#### 请求参数说明
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `file_path` | string | 是 | — | 文件路径 |
| `collection` | string | 是 | — | 向量库名称 |
| `difficulty` | int | 否 | 3 | 难度等级 1-5 |
| `max_total` | int | 否 | — | AI出题总数上限正整数。**不传则不限制** |
| `exclude_stems` | string[] | 否 | — | 排除已有题干 |
| `request_id` | string | 否 | — | 幂等性标识 |
| `options` | object | 否 | {} | 同 /exam/generate |
#### 成功响应
`/exam/generate` 格式相同,`data` 中额外包含 `ai_analysis` 字段:
```json
{
"success": true,
"status": "success",
"status_code": 2020,
"message": "AI 智能出题成功",
"data": {
"success": true,
"request_id": "uuid",
"questions": [ ... ],
"total": 15,
"requested_types": {"single_choice": 8, "true_false": 4, "fill_blank": 3},
"actual_types": {"single_choice": 8, "true_false": 4, "fill_blank": 3},
"source_chunks_used": 30,
"ai_analysis": {
"total_knowledge_points": 54,
"question_types": {"single_choice": 8, "true_false": 4, "fill_blank": 3},
"suitable_types": ["single_choice", "true_false", "fill_blank"],
"reason": "文档涵盖大量概念性内容..."
}
}
}
```
**ai_analysis 字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `total_knowledge_points` | int | AI 提取的知识点总数 |
| `question_types` | object | AI 推荐的题型和数量 |
| `suitable_types` | string[] | 适合该文档的题型列表 |
| `reason` | string | AI 的分析说明 |
> **注意**`requested_types` 来自 AI 分析结果(与 `ai_analysis.question_types` 一致),可能包含值为 0 的题型(表示 AI 认为该题型不适合当前文档)。`actual_types` 仅包含实际生成数量大于 0 的题型。
---
### 3.3 POST /exam/grade — 批题接口
逐题批阅并返回评分结果。支持 5 种题型混合批阅。
#### 请求体
```json
{
"request_id": "uuid-optional",
"answers": [
{
"question_id": "uuid-001",
"question_type": "single_choice",
"content": {"answer": "B"},
"student_answer": "A",
"max_score": 2
},
{
"question_id": "uuid-002",
"question_type": "multiple_choice",
"content": {"answer": ["A", "C"]},
"student_answer": ["A", "B"],
"max_score": 3
},
{
"question_id": "uuid-003",
"question_type": "true_false",
"content": {"answer": "对"},
"student_answer": "错",
"max_score": 2
},
{
"question_id": "uuid-004",
"question_type": "fill_blank",
"content": {"answer": [["答案1", "同义词"], ["答案2"]]},
"student_answer": ["学生答案1", "学生答案2"],
"max_score": 4
},
{
"question_id": "uuid-005",
"question_type": "subjective",
"content": {
"stem": "简述...",
"data": {"scoring_points": [{"point": "要点1", "weight": 0.5}, {"point": "要点2", "weight": 0.5}]},
"answer": "参考范文..."
},
"student_answer": "学生作答内容...",
"max_score": 10
}
]
}
```
#### 请求参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `request_id` | string | 否 | 幂等性标识 |
| `answers` | array | 是 | 答案列表,每项结构见下方 |
**answers 每道题的字段**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `question_id` | string | 是 | 题目唯一标识 |
| `question_type` | string | 是 | 题型,合法值同 question_types 键名 |
| `content` | object | 是 | 题目内容(与出题接口返回的 content 结构一致,后端可直接透传) |
| `student_answer` | any | 是 | 学生答案(类型随题型变化) |
| `max_score` | number | 否 | 该题满分默认客观题2/填空4/主观10 |
**各题型 content 和 student_answer 格式**
| 题型 | content.answer | student_answer |
|------|---------------|----------------|
| single_choice | `"B"` | `"A"` |
| multiple_choice | `["A", "C"]` | `["A", "B"]` |
| true_false | `"对"``"错"` | `"对"``"错"` |
| fill_blank | `[["答案1", "同义词"], ["答案2"]]` 每空一个数组(含可接受同义词) | `["学生答案1", "学生答案2"]` |
| subjective | 参考范文字符串 | 学生作答字符串 |
> **content 透传说明**`content` 字段的结构与出题接口返回的 `questions[].content` 完全一致。后端在存储题目时保存整个 `content` 对象,批题时原样传入即可。
#### 成功响应
```json
{
"success": true,
"status": "success",
"status_code": 2021,
"message": "批阅完成",
"data": {
"success": true,
"request_id": "uuid",
"results": [ ... ],
"total_score": 10.5,
"total_max_score": 21.0,
"score_rate": 50.0
}
}
```
**data 顶层字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| `success` | bool | 是否成功 |
| `request_id` | string/null | 请求标识 |
| `results` | array | 逐题评分结果(顺序与输入一致) |
| `total_score` | float | 总得分保留1位小数 |
| `total_max_score` | float | 总满分 |
| `score_rate` | float | 得分率百分比保留1位小数 |
---
### 3.4 GET /exam/health — 健康检查
```json
{
"status": "ok",
"service": "exam-api",
"version": "2.0"
}
```
---
## 四、题目数据结构
每道题(`questions` 数组中的元素)的完整结构:
```json
{
"question_type": "single_choice",
"difficulty": 3,
"content": {
"stem": "根据XX制度以下哪项是正确的",
"data": {
"options": [
{"key": "A", "content": "选项A内容"},
{"key": "B", "content": "选项B内容"},
{"key": "C", "content": "选项C内容"},
{"key": "D", "content": "选项D内容"}
]
},
"answer": "B",
"explanation": "根据XX制度第3条规定..."
},
"source_trace": {
"document_name": "public_kb/产品手册.pdf",
"chunk_ids": ["chunk_001", "chunk_002"],
"page_numbers": [3, 5],
"sources": [
{
"chunk_id": "chunk_001",
"page": 3,
"section": "第三章 管理制度",
"snippet": "原文相关片段前200字符..."
}
]
}
}
```
### 4.1 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `question_type` | string | 题型标识 |
| `difficulty` | int | 难度 1-5 |
| `content.stem` | string | 题干文本 |
| `content.data` | object | 题型相关数据(见下方) |
| `content.answer` | any | 正确答案(类型随题型变化) |
| `content.explanation` | string | 答案解析(可选) |
| `source_trace` | object | 溯源信息 |
### 4.2 各题型 content 差异
**单选题 (single_choice)**
```json
{
"content": {
"stem": "以下哪项是正确的?",
"data": {
"options": [
{"key": "A", "content": "..."},
{"key": "B", "content": "..."},
{"key": "C", "content": "..."},
{"key": "D", "content": "..."}
]
},
"answer": "B",
"explanation": "..."
}
}
```
**多选题 (multiple_choice)**
```json
{
"content": {
"stem": "以下哪些是正确的?(多选)",
"data": {
"options": [
{"key": "A", "content": "..."},
{"key": "B", "content": "..."},
{"key": "C", "content": "..."},
{"key": "D", "content": "..."}
]
},
"answer": ["A", "C"],
"explanation": "..."
}
}
```
**判断题 (true_false)**
```json
{
"content": {
"stem": "公司数据应定期备份(判断对错)",
"data": {},
"answer": "对",
"explanation": "..."
}
}
```
**填空题 (fill_blank)**
```json
{
"content": {
"stem": "公司财务报表应在每季度结束后______天内提交。",
"data": {
"blank_count": 1
},
"answer": [["15", "十五日"]],
"explanation": "..."
}
}
```
> `answer` 格式为二维数组:外层对应每空,内层为该空的可接受答案列表(第一个为标准答案,其余为同义词/等价答案)。
**主观题 (subjective)**
```json
{
"content": {
"stem": "简述公司数据安全的三道防线。",
"data": {
"scoring_points": [
{"point": "技术防线(防火墙、加密、访问控制)", "weight": 0.4},
{"point": "制度防线(安全规定、审批流程)", "weight": 0.3},
{"point": "人员防线(安全培训、意识教育)", "weight": 0.3}
]
},
"answer": "公司数据安全的三道防线包括1.技术防线...",
"explanation": "..."
}
}
```
> `scoring_points[].weight` 为该评分要点的权重0-1批题时 LLM 按要点逐项评分。
### 4.3 溯源信息 (source_trace)
| 字段 | 类型 | 说明 |
|------|------|------|
| `document_name` | string | 来源文件名 |
| `chunk_ids` | string[] | 来源切片 ID 列表 |
| `page_numbers` | int[] | 来源页码(已排序去重) |
| `sources` | object[] | 详细来源信息 |
| `sources[].chunk_id` | string | 切片 ID |
| `sources[].page` | int/null | 页码 |
| `sources[].section` | string | 所属章节标题 |
| `sources[].snippet` | string | 来源切片前 200 字符 |
---
## 五、批题结果数据结构
`results` 数组中每道题的评分结果,结构因题型而异:
### 5.1 客观题 (single_choice / multiple_choice / true_false)
```json
{
"question_id": "uuid-001",
"score": 2,
"max_score": 2,
"grading_status": "success",
"details": {
"correct": true,
"student_answer": "B",
"correct_answer": "B",
"feedback": "正确!"
}
}
```
### 5.2 填空题 (fill_blank)
```json
{
"question_id": "uuid-004",
"score": 2.0,
"max_score": 4,
"grading_status": "success",
"details": {
"blank_scores": [2.0, 0],
"total_blanks": 2,
"correct_blanks": 1
}
}
```
> 填空题支持部分给分。`blank_scores` 为每空得分,支持模糊匹配(编辑距离容错 + 标点归一化)。
### 5.3 主观题 (subjective)
```json
{
"question_id": "uuid-005",
"score": 7.5,
"max_score": 10,
"grading_status": "success",
"details": {
"scoring_breakdown": [
{
"point": "技术防线",
"weight": 0.4,
"achieved": 0.9,
"comment": "表述准确,覆盖了主要技术手段"
},
{
"point": "制度防线",
"weight": 0.3,
"achieved": 0.5,
"comment": "提到了审批流程但缺少具体制度名称"
},
{
"point": "人员防线",
"weight": 0.3,
"achieved": 0.8,
"comment": "安全培训和意识教育均有涉及"
}
],
"highlights": ["技术防线表述全面"],
"shortcomings": ["制度防线描述不够具体"],
"overall_feedback": "整体回答较好,建议补充制度层面的具体规定。"
}
}
```
### 5.4 批阅失败(兜底)
```json
{
"question_id": "uuid-005",
"score": 0,
"max_score": 10,
"grading_status": "failed",
"details": {
"error": "LLM 调用超时"
}
}
```
> 单题批阅失败不影响其他题目,失败题目得 0 分。
### 5.5 grading_status 取值
| 值 | 说明 |
|----|------|
| `success` | 评分成功 |
| `failed` | 评分失败LLM超时/解析异常score 为 0 |
---
## 六、出题管线内部逻辑(供参考)
v2 出题管线分四个阶段:
**Phase 1 — 章节分组**:检索文档切片,按章节标题聚类,构建文档结构概览。
**Phase 2 — 知识点提取**:逐章节调用 LLM 提取知识点列表。每次 LLM 调用间隔 1 秒防限流。
**Phase 3 — 精准出题**:按知识点 + 题型分配方案,逐知识点调用 LLM 生成题目。支持 429 限流自动重试(指数退避 1s→2s→4s
**Phase 4 — 后处理**
- 四层去重:题干精确去重 → 语义相似度去重 → 知识点均衡 → 跨调用去重exclude_stems
- 补题机制:某知识点出题失败时自动从其他知识点补题,补题与已有题干交叉去重
---
## 七、批阅逻辑说明(供参考)
| 题型 | 批阅方式 | 匹配策略 |
|------|----------|----------|
| single_choice | 精确匹配 | 大小写不敏感 |
| multiple_choice | 精确匹配 | 全部选对才给分 |
| true_false | 精确匹配 | 完全一致 |
| fill_blank | 模糊匹配 | 标点归一化 + 编辑距离容错(>=4字符允许<=2差异 |
| subjective | LLM 评分 | 按 scoring_points 逐项评分,支持并发批阅 |
---
## 八、后端对接指南
### 8.1 出题 → 存储 → 批题的典型流程
```
1. 后端调用 POST /exam/generate 或 /exam/generate-smart
2. RAG 返回 questions 数组
3. 后端将 questions 存入 MySQL保存完整 content 对象和 source_trace
4. 学生答题后,后端组装 answers 数组
- 将存储的 content 对象直接透传到 answers[].content
- 填入 student_answer 和 max_score
5. 后端调用 POST /exam/grade
6. RAG 返回 results 数组,后端存储评分结果
```
### 8.2 后端存储建议
题目表建议至少存储以下字段:
| 字段 | 来源 | 说明 |
|------|------|------|
| `question_id` | 后端自行分配 UUID | 出题接口不返回 ID由后端分配 |
| `question_type` | `questions[].question_type` | 题型标识 |
| `difficulty` | `questions[].difficulty` | 难度 |
| `content` | `questions[].content` | 完整 JSON 存储(含 stem/data/answer/explanation |
| `source_trace` | `questions[].source_trace` | 溯源 JSON |
| `source_file` | 请求参数 `file_path` | 来源文件 |
| `collection` | 请求参数 `collection` | 来源向量库 |
### 8.3 调用示例 (curl)
**参数出题**
```bash
curl -X POST http://<host>:5001/exam/generate \
-H "Content-Type: application/json" \
-d '{
"file_path": "public_kb/产品手册.pdf",
"collection": "public_kb",
"question_types": {"single_choice": 3, "true_false": 2},
"difficulty": 3
}'
```
**AI 智能出题限制20题**
```bash
curl -X POST http://<host>:5001/exam/generate-smart \
-H "Content-Type: application/json" \
-d '{
"file_path": "public_kb/产品手册.pdf",
"collection": "public_kb",
"max_total": 20
}'
```
**批题**
```bash
curl -X POST http://<host>:5001/exam/grade \
-H "Content-Type: application/json" \
-d '{
"answers": [
{
"question_id": "q-001",
"question_type": "single_choice",
"content": {"answer": "B"},
"student_answer": "B",
"max_score": 2
}
]
}'
```
---
## 九、性能参考
| 操作 | 预估耗时 | 说明 |
|------|----------|------|
| 参数出题5题 | 60-90s | 主要耗时在多次 LLM 调用 |
| AI 智能出题20题 | 3-5min | 含 AI 分析 + 多次 LLM 调用 |
| 批题10题混合 | 15-30s | 主观题 LLM 评分较慢 |
| 批题(纯客观题) | 1-3s | 无需 LLM纯匹配 |
> 耗时受 LLM 响应速度影响较大。使用推理模型(如 mimo-v2.5)时已自动关闭推理链以提速。
---
## 十、错误码汇总
| HTTP 状态码 | error_code | 场景 |
|------------|------------|------|
| 400 | MISSING_PARAMS | 缺少必填参数 |
| 400 | INVALID_PARAMS | 参数格式或值无效 |
| 500 | EXAM_ERROR | 出题过程异常 |
| 500 | GRADE_ERROR | 批阅过程异常 |
---
## 十一、注意事项
1. **题目 ID**:出题接口不生成 ID由后端在存储时分配 UUID。
2. **总题数上限**`/exam/generate` 单次请求不超过 20 题;`/exam/generate-smart` 通过 `max_total` 参数控制。
3. **排除题干**`exclude_stems` 最多 100 条,用于跨批次去重。后端可在多轮出题时将前批题干传入。
4. **填空题答案**:格式为二维数组 `[["标准答案", "同义词"], ...]`,批题时支持模糊匹配。
5. **主观题评分**:依赖 LLM存在一定非确定性。同一答案多次评分可能有小幅波动。
6. **同步阻塞**:所有接口为同步阻塞模式,请求直到完成后才返回。请后端设置合理的请求超时(建议 5 分钟以上)。
---
## 十二、变更记录
| 日期 | 版本 | 变更内容 |
|------|------|---------|
| 2026-06-22 | 3.0 | 全面重写:对接 v2 管线,更新为实际 API 格式5种题型、统一响应、generate-smart、max_total |
| 2026-06-04 | 2.1 | 更新模块结构 |
| 2026-04-13 | 2.0 | 合并出题批卷功能改造计划 |
| 2026-04-12 | 1.2 | 新增最小字段输入格式 |
| 2026-04-10 | 1.0 | 初始版本 |