2 Commits

Author SHA1 Message Date
lacerate551
6f863ddfd7 docs(exam): 出题批卷接口变更说明及文档同步
- 新增《出题批阅接口变更说明(2026-06-05)》面向后端的迁移指南
- 同步更新 curl测试手册 和 后端对接规范 中的出题批卷章节
2026-06-05 14:06:17 +08:00
lacerate551
3f1980ba78 feat(exam): 出题批卷 API 增强 — 格式统一、校验、跨调用去重
- grader: 多策略 JSON 提取 + 3 次重试 + prompt 优化,修复 LLM 输出不稳定问题
- grader: question_content → content 统一字段名,与出题接口一致
- api: 新增入参校验(题型枚举、difficulty、总题数上限 20、grade question_type)
- api/generator/manager: 新增 exclude_stems 参数,支持跨调用去重
2026-06-05 14:06:11 +08:00
7 changed files with 714 additions and 95 deletions

View File

@@ -1415,6 +1415,13 @@ curl -s http://localhost:5001/exam/health
- 需要手动指定 `question_types`(每种题型的数量) - 需要手动指定 `question_types`(每种题型的数量)
- 适合有明确需求的场景(如"出 5 道单选题" - 适合有明确需求的场景(如"出 5 道单选题"
**入参校验**
- `question_types` 中每个题型必须属于: `single_choice`, `multiple_choice`, `true_false`, `fill_blank`, `subjective`
- 每种题型数量必须为非负整数
- `difficulty` 必须为 1-5 的整数
- **总题数上限 20 道**(所有题型数量之和不能超过 20
- 校验失败返回 HTTP 400error_code 为 `INVALID_PARAMS`
```bash ```bash
curl -s -X POST http://localhost:5001/exam/generate \ curl -s -X POST http://localhost:5001/exam/generate \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -1430,6 +1437,7 @@ curl -s -X POST http://localhost:5001/exam/generate \
| `difficulty` | int | ❌ | 难度等级1-5默认 3 | | `difficulty` | int | ❌ | 难度等级1-5默认 3 |
| `options` | object | ❌ | 附加选项(如 `max_source_chunks` | | `options` | object | ❌ | 附加选项(如 `max_source_chunks` |
| `request_id` | string | ❌ | 请求 ID幂等性 | | `request_id` | string | ❌ | 请求 ID幂等性 |
| `exclude_stems` | string[] | ❌ | 已有题目题干列表(跨调用去重,最多 100 条) |
> **注意**:生产模式(`DEV_MODE=false`)下无需传 `Authorization` header直接放行。开发模式下可传 `Authorization: Bearer mock-token-admin` 模拟管理员身份。 > **注意**:生产模式(`DEV_MODE=false`)下无需传 `Authorization` header直接放行。开发模式下可传 `Authorization: Bearer mock-token-admin` 模拟管理员身份。
@@ -1474,7 +1482,10 @@ curl -s -X POST http://localhost:5001/exam/generate \
"request_id": null, "request_id": null,
"source_chunks_used": 15, "source_chunks_used": 15,
"success": true, "success": true,
"total": 10 "total": 10,
"requested_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
"actual_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
"warnings": []
}, },
"message": "出题成功", "message": "出题成功",
"status": "success", "status": "success",
@@ -1483,7 +1494,9 @@ curl -s -X POST http://localhost:5001/exam/generate \
} }
``` ```
**验证结果**:✅ 通过 > **说明**`warnings` 字段在某题型实际生成数量少于请求数量时返回提示信息。
**验证结果**:✅ 通过2026-06-05
--- ---
@@ -1562,7 +1575,7 @@ curl -s -X POST http://localhost:5001/exam/generate-smart \
批阅答案。 批阅答案。
```bash ```bash
echo '{"answers":[{"question_id":"q1","question_type":"single_choice","question_content":{"answer":"B"},"student_answer":"B","max_score":2}]}' > /tmp/grade.json echo '{"answers":[{"question_id":"q1","question_type":"single_choice","content":{"answer":"B"},"student_answer":"B","max_score":2}]}' > /tmp/grade.json
curl -s -X POST http://localhost:5001/exam/grade \ curl -s -X POST http://localhost:5001/exam/grade \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @/tmp/grade.json -d @/tmp/grade.json
@@ -1578,10 +1591,12 @@ curl -s -X POST http://localhost:5001/exam/grade \
|------|------|------|------| |------|------|------|------|
| `question_id` | string | ✅ | 题目 ID | | `question_id` | string | ✅ | 题目 ID |
| `question_type` | string | ✅ | 题型 | | `question_type` | string | ✅ | 题型 |
| `question_content` | object | ✅ | 题目内容(含标准答案 | | `content` | object | ✅ | 题目内容(与出题接口返回的 `content` 字段结构一致,后端可直接透传 |
| `student_answer` | string/array | ✅ | 学生答案 | | `student_answer` | string/array | ✅ | 学生答案 |
| `max_score` | int | ✅ | 满分 | | `max_score` | int | ✅ | 满分 |
> **注意**`question_type` 必须为以下值之一:`single_choice`, `multiple_choice`, `true_false`, `fill_blank`, `subjective`。传入无效类型返回 HTTP 400。
**响应示例** **响应示例**
```json ```json
{ {
@@ -1589,11 +1604,16 @@ curl -s -X POST http://localhost:5001/exam/grade \
"request_id": null, "request_id": null,
"results": [ "results": [
{ {
"correct": true,
"feedback": "正确!",
"max_score": 2,
"question_id": "q1", "question_id": "q1",
"score": 2 "score": 2,
"max_score": 2,
"grading_status": "success",
"details": {
"correct": true,
"student_answer": "B",
"correct_answer": "B",
"feedback": "正确!"
}
} }
], ],
"score_rate": 100.0, "score_rate": 100.0,
@@ -1608,7 +1628,13 @@ curl -s -X POST http://localhost:5001/exam/grade \
} }
``` ```
**验证结果**:✅ 通过 **`grading_status` 取值说明**
- `success`:评分成功
- `failed`:评分失败(主观题 LLM 解析失败或超时),此时 `details` 包含 `error` 字段
> **注意**:当主观题缺少 `scoring_points` 评分标准时,`details.warnings` 会包含警告信息,评分结果仅供参考。
**验证结果**:✅ 通过2026-06-05
--- ---

View File

@@ -0,0 +1,403 @@
# 出题批阅接口变更说明2026-06-05
> 本文档面向后端开发人员,汇总出题/批卷接口本次升级的所有变更点,以及后端需要适配的代码修改。
---
## 一、变更总览
| 变更项 | 变更类型 | 影响范围 | 后端是否必须改 |
|--------|----------|----------|---------------|
| 批卷请求字段 `question_content``content` | **破坏性变更** | `/exam/grade` 请求体 | **是** |
| 批卷结果格式统一为 `grading_status` + `details` | **格式变更** | `/exam/grade` 响应体 | **是** |
| 出题总题数上限 20 道 | 新增校验 | `/exam/generate` | 需注意 |
| 批卷 `question_type` 枚举校验 | 新增校验 | `/exam/grade` | 需注意 |
| **`exclude_stems` 跨调用去重** | **新增参数** | `/exam/generate``/exam/generate-smart` | **建议使用** |
| 出题结果新增 `requested_types``actual_types``warnings` | 新增字段 | `/exam/generate` 响应体 | 可选消费 |
| 主观题缺少评分标准时返回 `warnings` | 新增字段 | `/exam/grade` 响应体 | 可选消费 |
---
## 二、破坏性变更(必须修改)
### 2.1 批卷请求字段重命名:`question_content` → `content`
**变更前**
```json
{
"question_id": "q-001",
"question_type": "single_choice",
"question_content": {
"stem": "题干",
"answer": "B",
"data": {"options": [...]}
},
"student_answer": "B",
"max_score": 2
}
```
**变更后**
```json
{
"question_id": "q-001",
"question_type": "single_choice",
"content": {
"stem": "题干",
"answer": "B",
"data": {"options": [...]}
},
"student_answer": "B",
"max_score": 2
}
```
**为什么要改**:出题接口 `/exam/generate` 返回的每道题里,题目内容字段叫 `content`。批卷接口改用同名 `content` 后,后端可以直接把出题结果的 `content` 透传到批卷接口,不需要做任何字段映射。
**后端代码修改示例**
```python
# 修改前
grade_answers.append({
"question_id": qid,
"question_type": question.question_type,
"question_content": question.content, # ← 旧字段名
"student_answer": ans['answer'],
"max_score": question.score
})
# 修改后
grade_answers.append({
"question_id": qid,
"question_type": question.question_type,
"content": question.content, # ← 新字段名,与出题接口一致
"student_answer": ans['answer'],
"max_score": question.score
})
```
### 2.2 批卷结果格式统一
**变更前**:各题型返回格式不统一,客观题直接返回 `correct` + `feedback`,填空题返回 `details.blank_scores`,主观题返回 `details.scoring_breakdown`,没有统一的结构标识。
```json
// 旧 - 客观题
{"question_id": "q1", "score": 2, "max_score": 2, "correct": true, "feedback": "正确!"}
// 旧 - 填空题
{"question_id": "q4", "score": 2, "max_score": 4, "details": {"blank_scores": [2, 0]}}
// 旧 - 主观题
{"question_id": "q5", "score": 7, "max_score": 10, "details": {"scoring_breakdown": [...]}}
```
**变更后**:所有题型统一使用 `grading_status` + `details` 结构。
```json
// 新 - 客观题
{
"question_id": "q1",
"score": 2,
"max_score": 2,
"grading_status": "success",
"details": {
"correct": true,
"student_answer": "B",
"correct_answer": "B",
"feedback": "正确!"
}
}
// 新 - 填空题
{
"question_id": "q4",
"score": 2.0,
"max_score": 4.0,
"grading_status": "success",
"details": {
"total_blanks": 2,
"correct_blanks": 1,
"blank_scores": [2.0, 0],
"feedback": "2 个空中答对 1 个"
}
}
// 新 - 主观题
{
"question_id": "q5",
"score": 7.5,
"max_score": 10.0,
"grading_status": "success",
"details": {
"scoring_breakdown": [
{"point": "核心概念", "weight": 0.5, "achieved": 0.8, "comment": "概念描述准确"}
],
"highlights": ["条理清晰"],
"shortcomings": ["缺少应用场景"],
"overall_feedback": "整体回答较好,建议补充实际应用场景。"
}
}
// 新 - 评分失败LLM 异常或超时)
{
"question_id": "q6",
"score": 0,
"max_score": 10.0,
"grading_status": "failed",
"details": {
"error": "评分结果解析失败"
}
}
```
**后端需要关注的点**
1. **判断评分是否成功**:改用 `grading_status` 字段判断,不再依赖 `correct``score > 0`
- `"success"` → 正常评分,`score``details` 有效
- `"failed"` → 评分失败,`score` 为 0`details.error` 包含失败原因
2. **获取反馈信息**:原来客观题的 `correct`/`feedback` 现在统一在 `details`
3. **成绩更新逻辑**`grading_status: "failed"` 的题目,后端可选择标记为"待重批"而非直接记 0 分
**后端代码修改示例**
```python
# 修改前
for result in grade_result['results']:
is_correct = result.get('correct', False)
feedback = result.get('feedback', '')
# 更新成绩...
# 修改后
for result in grade_result['results']:
status = result.get('grading_status', 'success')
details = result.get('details', {})
if status == 'failed':
# 评分失败,标记待重批
mark_for_regrade(result['question_id'], details.get('error', ''))
continue
is_correct = details.get('correct', None) # 仅客观题有此字段
feedback = details.get('feedback', '')
# 更新成绩...
```
---
## 三、新增校验规则(需注意)
### 3.1 出题接口入参校验
`POST /exam/generate` 新增以下校验,不满足时返回 **HTTP 400**`error_code``INVALID_PARAMS`
| 校验项 | 规则 | 示例(会返回 400 |
|--------|------|-------------------|
| 题型合法性 | `question_types` 的 key 必须属于 5 种题型 | `{"essay": 2}` → 不支持 |
| 题型数量 | 每种题型数量必须为非负整数 | `{"single_choice": -1}` → 不合法 |
| 难度范围 | `difficulty` 必须为 1-5 的整数 | `"difficulty": 10` → 超范围 |
| **总题数上限** | **所有题型数量之和不能超过 20** | `{"single_choice": 15, "true_false": 10}` → 超限 |
合法的 5 种题型 key`single_choice``multiple_choice``true_false``fill_blank``subjective`
**后端建议**:在前端提交出题请求时做前置校验,避免不必要的网络请求。
### 3.2 批卷接口 question_type 校验
`POST /exam/grade``answers` 数组中,每道题的 `question_type` 现在也会校验。无效值返回 **HTTP 400**
```json
// 返回 400
{"answers": [{"question_type": "essay", ...}]}
// 错误信息
{"error_code": "INVALID_PARAMS", "message": "第 1 题的 question_type 无效: essay合法值: fill_blank, multiple_choice, single_choice, subjective, true_false"}
```
### 3.3 错误响应格式
校验失败统一返回:
```json
{
"success": false,
"error_code": "INVALID_PARAMS",
"message": "具体错误描述",
"status": "failed",
"status_code": 4000
}
```
---
## 三点五、跨调用去重:`exclude_stems`(强烈建议使用)
### 背景问题
同一份文档多次调用出题接口时,由于知识点来源相同,新生成的题目很可能与已有题目高度重复。单次调用上限 20 道,当需要更多题目时,这个问题尤为突出。
### 解决方式
两个出题接口(`/exam/generate``/exam/generate-smart`)新增可选参数 `exclude_stems`后端把该文件已入库的题目题干传过来RAG 在生成后自动排除匹配的题目。
**请求示例**
```json
POST /exam/generate
{
"file_path": "public_kb/产品手册.pdf",
"collection": "public_kb",
"question_types": {"single_choice": 10},
"exclude_stems": [
"重购率的定义是下列哪一项?",
"以下哪项属于现代终端分类?",
"客户满意度的计算公式为___"
]
}
```
### 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `exclude_stems` | string[] | 否 | 已有题目的题干列表,最多 100 条 |
### 去重策略
- 基于题干前 80 个字符进行匹配
- 新生成的题目如果与 `exclude_stems` 中任意一条匹配,会被自动过滤
- 不传或传空数组时行为不变(向后兼容)
### 后端使用流程
```
第一次出题:
POST /exam/generate { question_types: {...} }
→ 返回 20 道新题 → 审核后入库
第二次出题(想多出 10 道选择题):
1. 查询数据库,获取该文件已有的所有题目题干
2. POST /exam/generate {
question_types: {"single_choice": 10},
exclude_stems: ["已有题干1", "已有题干2", ...]
}
→ 返回 10 道不与已有题目重复的新题
```
**后端代码示例**
```python
def generate_more_questions(file_path, collection, question_types):
# 1. 查询该文件已有的题目题干
existing_stems = db.query("""
SELECT JSON_EXTRACT(content, '$.stem')
FROM questions
WHERE source_file = ?
""", [file_path])
# 2. 调用出题接口,传入已有题干
response = requests.post('http://rag-service:5001/exam/generate', json={
'file_path': file_path,
'collection': collection,
'question_types': question_types,
'exclude_stems': [row[0] for row in existing_stems]
})
return response.json()
```
---
## 四、新增字段(可选消费)
### 4.1 出题结果新增字段
`POST /exam/generate``POST /exam/generate-smart``data` 对象新增三个字段:
```json
{
"data": {
"questions": [...],
"total": 8,
"requested_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
"actual_types": {"single_choice": 5, "true_false": 3, "fill_blank": 0},
"warnings": ["fill_blank: 请求 2 道,实际生成 0 道"],
...
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `requested_types` | object | 后端请求的题型和数量(原样回传) |
| `actual_types` | object | 实际生成的题型和数量 |
| `warnings` | string[] | 短缺提示数组,全部生成成功时为空数组 |
**后端建议**:检查 `warnings` 是否为空,不为空时可提示用户"部分题型生成不足",或自动补发请求。
### 4.2 主观题评分警告
当主观题的 `content` 中缺少 `data.scoring_points` 评分标准时,批卷结果会在 `details.warnings` 中给出提示:
```json
{
"question_id": "q5",
"score": 7.0,
"max_score": 10.0,
"grading_status": "success",
"details": {
"scoring_breakdown": [...],
"overall_feedback": "...",
"warnings": ["缺少评分标准(scoring_points),评分结果仅供参考"]
}
}
```
此时 `grading_status` 仍为 `"success"`,但评分准确度可能受影响。后端可在前端展示时附上"评分仅供参考"的提示。
---
## 五、完整的出题→入库→批卷数据流(更新后)
```
┌──────────────────────────────────────────────────────────────────────────┐
│ 后端完整流程 │
│ │
│ 1. 调用出题接口 │
│ POST /exam/generate │
│ → 首次出题:不传 exclude_stems │
│ → 追加出题:查询已有题干,传入 exclude_stems 避免重复 │
│ → 返回 questions[],每题包含 question_type, content, source_trace │
│ → 检查 warnings 是否有短缺提示 │
│ │
│ 2. 审核入库(后端自行决定) │
│ → 可删除/修改不满意的题目 │
│ → 存入数据库时保留 content 字段原样 │
│ → 生成 question_id (UUID),设置 max_score │
│ │
│ 3. 学生作答 │
│ → 收集 student_answer │
│ │
│ 4. 调用批卷接口 │
│ POST /exam/grade │
│ → content 直接从数据库取出透传(与出题接口返回的结构一致) │
│ → 检查 grading_status 判断每题是否评分成功 │
│ → grading_status=failed 的题目可标记待重批 │
│ │
│ 5. 更新成绩 │
│ → 根据 question_id 匹配结果,写入学生成绩表 │
└──────────────────────────────────────────────────────────────────────────┘
```
**关键要点**:出题返回的 `content` 和批卷接收的 `content` 结构完全一致,后端只需原样存取即可,无需任何字段转换。
---
## 六、后端修改清单
| 序号 | 修改项 | 紧急程度 | 说明 |
|------|--------|----------|------|
| 1 | 批卷请求 `question_content` 改为 `content` | **必须** | 否则批卷接口无法读取题目内容,所有评分失败 |
| 2 | 批卷结果解析改用 `grading_status` + `details` | **必须** | 否则无法正确获取评分详情 |
| 3 | 处理 `grading_status: "failed"` 情况 | **建议** | 避免将 LLM 异常导致的 0 分直接记入成绩 |
| 4 | **追加出题时传入 `exclude_stems`** | **强烈建议** | 避免同一文件多次出题产生重复题目 |
| 5 | 出题请求总题数控制在 20 以内 | **建议** | 否则返回 400 错误 |
| 6 | 前端校验 question_types 和 difficulty | **建议** | 减少无效请求 |
| 7 | 消费 `warnings` 字段做短缺提示 | 可选 | 提升用户体验 |
| 8 | 消费主观题 `details.warnings` | 可选 | 提示评分可信度 |

View File

@@ -2,9 +2,11 @@
--- ---
## 📋 变更记录2026-05-28 更新) ## 📋 变更记录2026-06-05 更新)
> **本次更新内容**:新增 AI 智能出题端口、更新生产环境测试结果 > **本次更新内容**:新增 AI 智能出题端口、更新生产环境测试结果
>
> **2026-06-05 更新**:出题批卷接口格式优化与输入校验增强
### 新增端口 ### 新增端口
@@ -55,6 +57,17 @@
| `/images/list` | 200 | ✅ 正常 | | `/images/list` | 200 | ✅ 正常 |
| `/documents/sync` | 200 | ✅ 正常(已修复) | | `/documents/sync` | 200 | ✅ 正常(已修复) |
### 出题批卷接口变更2026-06-05
| 变更项 | 旧值 | 新值 | 影响 |
|--------|------|------|------|
| 批卷请求字段名 | `question_content` | `content` | ⚠️ **破坏性变更**,后端需同步修改 |
| 出题总题数上限 | 无限制 | **20 道** | 超过返回 400 |
| 批卷 question_type | 无校验 | 必须属于 5 种合法题型 | 无效值返回 400 |
| 批卷结果格式 | 各题型格式不统一 | 统一 `grading_status` + `details` | 后端需适配新格式 |
| **跨调用去重** | 无 | **`exclude_stems` 参数** | 追加出题时传入已有题干避免重复 |
| 出题结果 | 无短缺提示 | 新增 `warnings` 字段 | 可选消费 |
--- ---
## 一、服务概述 ## 一、服务概述
@@ -1387,6 +1400,7 @@ POST /exam/generate
| `question_types` | object | ✅ | 题型及数量,键为题型名,值为数量 | | `question_types` | object | ✅ | 题型及数量,键为题型名,值为数量 |
| `difficulty` | int | ❌ | 难度等级 1-5默认 3 | | `difficulty` | int | ❌ | 难度等级 1-5默认 3 |
| `request_id` | string | ❌ | 请求 ID相同 ID 返回缓存结果(幂等性) | | `request_id` | string | ❌ | 请求 ID相同 ID 返回缓存结果(幂等性) |
| `exclude_stems` | string[] | ❌ | 已有题目题干列表,用于跨调用去重(最多 100 条,传空或不传则不去重) |
**collection 参数说明:** **collection 参数说明:**
@@ -1394,6 +1408,15 @@ POST /exam/generate
- 单个向量库:`"dept_a_kb"` - 单个向量库:`"dept_a_kb"`
- 多个向量库:`["dept_a_kb", "public_kb"]`(按优先级排序,优先在第一个库检索) - 多个向量库:`["dept_a_kb", "public_kb"]`(按优先级排序,优先在第一个库检索)
#### 入参校验规则2026-06-05 新增)
| 校验项 | 规则 | 失败返回 |
|--------|------|----------|
| question_types 题型 | 必须属于: single_choice, multiple_choice, true_false, fill_blank, subjective | HTTP 400 INVALID_PARAMS |
| question_types 数量 | 每种题型数量必须为非负整数 | HTTP 400 INVALID_PARAMS |
| difficulty | 必须为 1-5 的整数 | HTTP 400 INVALID_PARAMS |
| **总题数上限** | **所有题型数量之和不能超过 20** | HTTP 400 INVALID_PARAMS |
**响应:** **响应:**
**完整响应格式**(包含外层包装): **完整响应格式**(包含外层包装):
@@ -1420,6 +1443,9 @@ POST /exam/generate
"request_id": "xxx", "request_id": "xxx",
"total": 10, "total": 10,
"source_chunks_used": 15, "source_chunks_used": 15,
"requested_types": {"single_choice": 3, "true_false": 2, "fill_blank": 2},
"actual_types": {"single_choice": 3, "true_false": 2, "fill_blank": 2},
"warnings": [],
"questions": [ "questions": [
{ {
"question_type": "single_choice", "question_type": "single_choice",
@@ -1455,6 +1481,8 @@ POST /exam/generate
} }
``` ```
> **`warnings` 字段说明**:当某题型实际生成数量少于请求数量时,`warnings` 数组会返回短缺提示(如 `["single_choice: 请求 5 道,实际生成 3 道"]`)。后端可据此判断是否需要重新出题。正常情况该数组为空。
**题型与 answer 格式对照:** **题型与 answer 格式对照:**
| 题型 | question_type | answer 格式 | data 字段 | | 题型 | question_type | answer 格式 | data 字段 |
@@ -1492,6 +1520,7 @@ POST /exam/generate
| `FILE_NOT_FOUND` | 404 | 指定文件不存在 | | `FILE_NOT_FOUND` | 404 | 指定文件不存在 |
| `COLLECTION_NOT_FOUND` | 404 | 指定向量库不存在 | | `COLLECTION_NOT_FOUND` | 404 | 指定向量库不存在 |
| `NO_CONTENT` | 400 | 文件内容为空,无法出题 | | `NO_CONTENT` | 400 | 文件内容为空,无法出题 |
| `INVALID_PARAMS` | 400 | 入参校验失败题型不合法、数量超限、difficulty 范围错误等) |
| `LLM_ERROR` | 500 | LLM 调用失败 | | `LLM_ERROR` | 500 | LLM 调用失败 |
| `PARSE_ERROR` | 500 | 解析 LLM 响应失败 | | `PARSE_ERROR` | 500 | 解析 LLM 响应失败 |
@@ -1503,6 +1532,8 @@ POST /exam/generate
### 7.2 批改答案 ### 7.2 批改答案
> **⚠️ 重要变更2026-06-05**:批卷请求中的 `question_content` 字段已更名为 `content`,与出题接口返回的题目 `content` 字段保持一致。后端可直接将出题结果的 `content` 透传到批卷接口,无需额外转换。
``` ```
POST /exam/grade POST /exam/grade
``` ```
@@ -1516,7 +1547,7 @@ POST /exam/grade
{ {
"question_id": "uuid-001", "question_id": "uuid-001",
"question_type": "single_choice", "question_type": "single_choice",
"question_content": { "content": {
"stem": "题干内容", "stem": "题干内容",
"data": {"options": [{"key": "A", "content": "选项A"}, {"key": "B", "content": "选项B"}]}, "data": {"options": [{"key": "A", "content": "选项A"}, {"key": "B", "content": "选项B"}]},
"answer": "B" "answer": "B"
@@ -1527,7 +1558,7 @@ POST /exam/grade
{ {
"question_id": "uuid-002", "question_id": "uuid-002",
"question_type": "multiple_choice", "question_type": "multiple_choice",
"question_content": { "content": {
"stem": "多选题题干", "stem": "多选题题干",
"data": {"options": [...]}, "data": {"options": [...]},
"answer": ["A", "C"] "answer": ["A", "C"]
@@ -1538,7 +1569,7 @@ POST /exam/grade
{ {
"question_id": "uuid-003", "question_id": "uuid-003",
"question_type": "true_false", "question_type": "true_false",
"question_content": { "content": {
"stem": "判断题题干", "stem": "判断题题干",
"answer": "T" "answer": "T"
}, },
@@ -1548,7 +1579,7 @@ POST /exam/grade
{ {
"question_id": "uuid-004", "question_id": "uuid-004",
"question_type": "fill_blank", "question_type": "fill_blank",
"question_content": { "content": {
"stem": "填空题有___个空", "stem": "填空题有___个空",
"answer": [["答案1", "同义词1"], ["答案2"]] "answer": [["答案1", "同义词1"], ["答案2"]]
}, },
@@ -1558,7 +1589,7 @@ POST /exam/grade
{ {
"question_id": "uuid-005", "question_id": "uuid-005",
"question_type": "subjective", "question_type": "subjective",
"question_content": { "content": {
"stem": "简答题题干", "stem": "简答题题干",
"data": { "data": {
"scoring_points": [ "scoring_points": [
@@ -1587,8 +1618,8 @@ POST /exam/grade
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `question_id` | string | **是** | 题目 ID后端生成用于匹配结果 | | `question_id` | string | **是** | 题目 ID后端生成用于匹配结果 |
| `question_type` | string | 是 | 题型single_choice/multiple_choice/true_false/fill_blank/subjective | | `question_type` | string | 是 | 题型**必须属于**single_choice/multiple_choice/true_false/fill_blank/subjective,无效值返回 HTTP 400 |
| `question_content` | object | 是 | 题目内容(从出题结果中获取) | | `content` | object | 是 | 题目内容(从出题结果中获取) |
| `student_answer` | any | 是 | 学生答案(格式见下表) | | `student_answer` | any | 是 | 学生答案(格式见下表) |
| `max_score` | number | 是 | 该题满分 | | `max_score` | number | 是 | 该题满分 |
@@ -1636,36 +1667,31 @@ POST /exam/grade
"question_id": "uuid-001", "question_id": "uuid-001",
"score": 0, "score": 0,
"max_score": 2.0, "max_score": 2.0,
"grading_status": "success",
"details": {
"correct": false, "correct": false,
"student_answer": "A",
"correct_answer": "B",
"feedback": "正确答案: B" "feedback": "正确答案: B"
}, }
{
"question_id": "uuid-002",
"score": 0,
"max_score": 4.0,
"correct": false,
"feedback": "正确答案: ['A', 'C']"
},
{
"question_id": "uuid-003",
"score": 0,
"max_score": 2.0,
"correct": false,
"feedback": "正确答案: T"
}, },
{ {
"question_id": "uuid-004", "question_id": "uuid-004",
"score": 2.0, "score": 2.0,
"max_score": 4.0, "max_score": 4.0,
"grading_status": "success",
"details": { "details": {
"total_blanks": 2,
"correct_blanks": 1,
"blank_scores": [2.0, 0], "blank_scores": [2.0, 0],
"correct_answers": [["答案1", "同义词1"], ["答案2"]] "feedback": "2 个空中答对 1 个"
} }
}, },
{ {
"question_id": "uuid-005", "question_id": "uuid-005",
"score": 7.5, "score": 7.5,
"max_score": 10.0, "max_score": 10.0,
"grading_status": "success",
"details": { "details": {
"scoring_breakdown": [ "scoring_breakdown": [
{"point": "要点1", "weight": 0.4, "achieved": 0.35, "comment": "部分掌握"}, {"point": "要点1", "weight": 0.4, "achieved": 0.35, "comment": "部分掌握"},
@@ -1673,13 +1699,30 @@ POST /exam/grade
], ],
"highlights": ["思路清晰"], "highlights": ["思路清晰"],
"shortcomings": ["细节不够完整"], "shortcomings": ["细节不够完整"],
"overall_feedback": "整体回答良好,建议补充细节。" "overall_feedback": "整体回答良好",
"warnings": ["缺少评分标准(scoring_points),评分结果仅供参考"]
}
},
{
"question_id": "uuid-006",
"score": 0,
"max_score": 10.0,
"grading_status": "failed",
"details": {
"error": "评分结果解析失败"
} }
} }
] ]
} }
``` ```
#### grading_status 状态说明2026-06-05 新增)
| 状态 | 说明 |
|------|------|
| `success` | 评分成功score 和 details 有效 |
| `failed` | 评分失败LLM 解析失败或超时score 为 0details 包含 error 描述 |
#### 批卷逻辑说明 #### 批卷逻辑说明
| 题型 | 批卷方式 | 说明 | | 题型 | 批卷方式 | 说明 |
@@ -1766,7 +1809,7 @@ def build_grade_request(answer_list, questions_map):
grade_answers.append({ grade_answers.append({
"question_id": qid, "question_id": qid,
"question_type": question.question_type, "question_type": question.question_type,
"question_content": question.content, # 含正确答案 "content": question.content, # 含正确答案,与出题接口 content 结构一致
"student_answer": ans['answer'], "student_answer": ans['answer'],
"max_score": question.score "max_score": question.score
}) })
@@ -1854,8 +1897,8 @@ def grade_student_exam(student_answers):
|------|------|------| |------|------|------|
| `question_id` | 后端数据库 | 用于匹配返回结果,更新成绩 | | `question_id` | 后端数据库 | 用于匹配返回结果,更新成绩 |
| `question_type` | 后端数据库 | 题型,决定批卷方式 | | `question_type` | 后端数据库 | 题型,决定批卷方式 |
| `question_content.answer` | 后端数据库 | 正确答案(客观题直接比对,主观题作为参考) | | `content.answer` | 后端数据库 | 正确答案(客观题直接比对,主观题作为参考) |
| `question_content.data` | 后端数据库 | 题目附加数据(选项、评分标准等) | | `content.data` | 后端数据库 | 题目附加数据(选项、评分标准等) |
| `student_answer` | 学生提交 | 学生作答内容 | | `student_answer` | 学生提交 | 学生作答内容 |
| `max_score` | 后端数据库 | 该题满分 | | `max_score` | 后端数据库 | 该题满分 |

View File

@@ -61,6 +61,22 @@ def validate_difficulty(difficulty) -> str:
return "difficulty 必须为 1-5 的整数" return "difficulty 必须为 1-5 的整数"
return None return None
MAX_EXCLUDE_STEMS = 100
def validate_exclude_stems(exclude_stems) -> str:
"""校验 exclude_stems 参数(可选)"""
if exclude_stems is None:
return None
if not isinstance(exclude_stems, list):
return "exclude_stems 必须为字符串数组"
if len(exclude_stems) > MAX_EXCLUDE_STEMS:
return f"exclude_stems 数量不能超过 {MAX_EXCLUDE_STEMS},当前 {len(exclude_stems)}"
for i, stem in enumerate(exclude_stems):
if not isinstance(stem, str) or not stem.strip():
return f"exclude_stems 第 {i+1} 项必须为非空字符串"
return None
# 创建蓝图 # 创建蓝图
exam_bp = Blueprint('exam', __name__) exam_bp = Blueprint('exam', __name__)
@@ -86,6 +102,7 @@ def api_generate_questions():
"subjective": 1 "subjective": 1
}, },
"difficulty": 3, "difficulty": 3,
"exclude_stems": ["已有题目的题干1", "已有题目的题干2"], // 可选,排除已有题目避免重复
"options": { "options": {
"include_explanation": true, "include_explanation": true,
"max_source_chunks": 50 "max_source_chunks": 50
@@ -135,6 +152,20 @@ def api_generate_questions():
if diff_error: if diff_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400) return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400)
# 校验总题数上限
MAX_TOTAL_QUESTIONS = 20
total_requested = sum(question_types.values())
if total_requested > MAX_TOTAL_QUESTIONS:
return error_response("INVALID_PARAMS", BAD_REQUEST,
f"总题数不能超过 {MAX_TOTAL_QUESTIONS} 道,当前请求 {total_requested}",
http_status=400)
# 校验排除题干列表(可选)
exclude_stems = data.get('exclude_stems')
stems_error = validate_exclude_stems(exclude_stems)
if stems_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, stems_error, http_status=400)
# 获取当前用户 # 获取当前用户
user = get_current_user() user = get_current_user()
if not user: if not user:
@@ -156,7 +187,8 @@ def api_generate_questions():
question_types=question_types, question_types=question_types,
difficulty=data.get('difficulty', 3), difficulty=data.get('difficulty', 3),
options=data.get('options', {}), options=data.get('options', {}),
request_id=data.get('request_id') request_id=data.get('request_id'),
exclude_stems=data.get('exclude_stems')
) )
return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功") return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功")
@@ -199,6 +231,12 @@ def api_generate_smart():
if diff_error: if diff_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400) return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400)
# 校验排除题干列表(可选)
exclude_stems = data.get('exclude_stems')
stems_error = validate_exclude_stems(exclude_stems)
if stems_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, stems_error, http_status=400)
# 获取当前用户 # 获取当前用户
user = get_current_user() user = get_current_user()
if not user: if not user:
@@ -231,7 +269,8 @@ def api_generate_smart():
question_types=question_types, question_types=question_types,
difficulty=data.get('difficulty', 3), difficulty=data.get('difficulty', 3),
options=data.get('options', {}), options=data.get('options', {}),
request_id=data.get('request_id') request_id=data.get('request_id'),
exclude_stems=data.get('exclude_stems')
) )
# 3. 在返回结果中添加 AI 分析信息 # 3. 在返回结果中添加 AI 分析信息
@@ -258,21 +297,21 @@ def api_grade_answers():
{ {
"question_id": "uuid", "question_id": "uuid",
"question_type": "single_choice", "question_type": "single_choice",
"question_content": {"answer": "B"}, "content": {"answer": "B"},
"student_answer": "A", "student_answer": "A",
"max_score": 2 "max_score": 2
}, },
{ {
"question_id": "uuid", "question_id": "uuid",
"question_type": "fill_blank", "question_type": "fill_blank",
"question_content": {"answer": [["答案1"], ["答案2"]]}, "content": {"answer": [["答案1", "同义词"], ["答案2"]]},
"student_answer": ["学生答案1", "学生答案2"], "student_answer": ["学生答案1", "学生答案2"],
"max_score": 4 "max_score": 4
}, },
{ {
"question_id": "uuid", "question_id": "uuid",
"question_type": "subjective", "question_type": "subjective",
"question_content": { "content": {
"stem": "简述...", "stem": "简述...",
"data": {"scoring_points": [...]}, "data": {"scoring_points": [...]},
"answer": "参考范文..." "answer": "参考范文..."
@@ -283,6 +322,12 @@ def api_grade_answers():
] ]
} }
说明:
- content 字段与出题接口返回的 content 结构一致,后端可直接透传
- 客观题content 只需包含 answer
- 填空题content.answer 格式为 [["答案1", "同义词"], ...] 每空一个列表
- 主观题content 需包含 stem + answer + data.scoring_points
返回: 返回:
{ {
"success": true, "success": true,
@@ -312,6 +357,14 @@ def api_grade_answers():
if not answers: if not answers:
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少答案数据", http_status=400) return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少答案数据", http_status=400)
# 校验每道题的 question_type
for i, ans in enumerate(answers):
q_type = ans.get('question_type')
if q_type not in VALID_QUESTION_TYPES:
return error_response("INVALID_PARAMS", BAD_REQUEST,
f"{i+1} 题的 question_type 无效: {q_type},合法值: {', '.join(sorted(VALID_QUESTION_TYPES))}",
http_status=400)
# 调用新版批题接口 # 调用新版批题接口
result = grade_answers( result = grade_answers(
answers=answers, answers=answers,

View File

@@ -1114,7 +1114,8 @@ def generate_questions_structured_v2(
chunks: List[Dict], chunks: List[Dict],
document_name: str, document_name: str,
question_types: Dict[str, int], question_types: Dict[str, int],
difficulty: int = 3 difficulty: int = 3,
exclude_stems: List[str] = None
) -> List[Dict]: ) -> List[Dict]:
""" """
🔥 重构版:结构化出题 v2 🔥 重构版:结构化出题 v2
@@ -1130,6 +1131,7 @@ def generate_questions_structured_v2(
document_name: 文档名称 document_name: 文档名称
question_types: 题型及数量 {"single_choice": 5, ...} question_types: 题型及数量 {"single_choice": 5, ...}
difficulty: 难度 1-5 difficulty: 难度 1-5
exclude_stems: 需要排除的已有题目题干列表(跨调用去重)
Returns: Returns:
题目列表 题目列表
@@ -1169,7 +1171,7 @@ def generate_questions_structured_v2(
logger.warning("[v2] 知识点提取失败,降级使用 chunks 出题") logger.warning("[v2] 知识点提取失败,降级使用 chunks 出题")
fallback_questions = generator._generate_questions_fallback(chunks, document_name, question_types, difficulty) fallback_questions = generator._generate_questions_fallback(chunks, document_name, question_types, difficulty)
# 降级路径也执行去重和数量平衡 # 降级路径也执行去重和数量平衡
deduped = _deduplicate_questions(fallback_questions) deduped = _deduplicate_questions(fallback_questions, exclude_stems=exclude_stems)
final = _balance_question_types(deduped, question_types) final = _balance_question_types(deduped, question_types)
logger.info(f" [降级] 最终题目数: {len(final)}") logger.info(f" [降级] 最终题目数: {len(final)}")
return final return final
@@ -1225,9 +1227,9 @@ def generate_questions_structured_v2(
# ========== Phase 4: 质量校验 ========== # ========== Phase 4: 质量校验 ==========
logger.info("[v2] Phase 4: 质量校验") logger.info("[v2] Phase 4: 质量校验")
# 4.1 去重 # 4.1 去重(含跨调用排除已有题目)
deduped = _deduplicate_questions(all_questions) deduped = _deduplicate_questions(all_questions, exclude_stems=exclude_stems)
logger.info(f" 去重: {len(all_questions)}{len(deduped)}") logger.info(f" 去重: {len(all_questions)}{len(deduped)}" + (f"(排除已有 {len(exclude_stems)} 道)" if exclude_stems else ""))
# 4.2 数量校正 # 4.2 数量校正
final = _balance_question_types(deduped, question_types) final = _balance_question_types(deduped, question_types)
@@ -1381,22 +1383,31 @@ def _retrieve_kp_chunks_v2(
return result return result
def _deduplicate_questions(questions: List[Dict]) -> List[Dict]: def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = None) -> List[Dict]:
""" """
题目去重 题目去重
策略: 策略:
1. 相同题干去重(前 80 字) 1. 相同题干去重(前 80 字)
2. 相同知识点 + 相同题型去重 2. 相同知识点 + 相同题型去重
3. 排除已有题目(跨调用去重)
Args: Args:
questions: 原始题目列表 questions: 原始题目列表
exclude_stems: 需要排除的已有题目题干列表(用于跨调用去重)
Returns: Returns:
去重后的题目列表 去重后的题目列表
""" """
seen_stems = set() seen_stems = set()
seen_kp_type = set() seen_kp_type = set()
# 预填已有题目的题干前缀,使新生成的题目与已有题目冲突时被过滤
if exclude_stems:
for stem in exclude_stems:
seen_stems.add(stem[:80])
seen_kp_type.add(f"{stem[:30]}_") # 通配题型匹配
deduped = [] deduped = []
for q in questions: for q in questions:

View File

@@ -15,6 +15,8 @@
""" """
import json import json
import logging
import re
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -24,6 +26,8 @@ from typing import List, Dict, Any, Optional
# 导入 LLM 工具函数 # 导入 LLM 工具函数
from core.llm_utils import call_llm from core.llm_utils import call_llm
logger = logging.getLogger(__name__)
# 导入 LLM 配置 # 导入 LLM 配置
try: try:
from config import API_KEY, BASE_URL, MODEL from config import API_KEY, BASE_URL, MODEL
@@ -77,7 +81,7 @@ def grade_objective(answer: Dict) -> Dict:
🔥 本地直接判断,无 LLM 调用 🔥 本地直接判断,无 LLM 调用
""" """
q_type = answer['question_type'] q_type = answer['question_type']
question_content = answer.get('question_content', {}) question_content = answer.get('content', {})
correct_answer = question_content.get('answer') correct_answer = question_content.get('answer')
student_answer = answer.get('student_answer') student_answer = answer.get('student_answer')
max_score = answer.get('max_score', 2.0) max_score = answer.get('max_score', 2.0)
@@ -114,7 +118,7 @@ def grade_fill_blank(answer: Dict) -> Dict:
填空题答案格式:[["答案1", "同义词1", ...], ["答案2", ...], ...] 填空题答案格式:[["答案1", "同义词1", ...], ["答案2", ...], ...]
学生答案格式:["学生答案1", "学生答案2", ...] 学生答案格式:["学生答案1", "学生答案2", ...]
""" """
question_content = answer.get('question_content', {}) question_content = answer.get('content', {})
correct_answers = question_content.get('answer', []) # [[答案1, 同义词...], ...] correct_answers = question_content.get('answer', []) # [[答案1, 同义词...], ...]
student_answers = answer.get('student_answer', []) student_answers = answer.get('student_answer', [])
max_score = answer.get('max_score', 4.0) max_score = answer.get('max_score', 4.0)
@@ -284,33 +288,48 @@ class AnswerGrader:
"details": {"error": "批阅超时"} "details": {"error": "批阅超时"}
} }
@retry(times=2, delay=1) @retry(times=3, delay=1)
def _grade_subjective(self, answer: Dict) -> Dict: def _grade_subjective(self, answer: Dict) -> Dict:
""" """
批阅主观题 - 调用 LLM 评分 批阅主观题 - 调用 LLM 评分
🔥 P1 改进:带重试 🔥 改进3次重试 + 解析失败自动重试
""" """
with grading_semaphore: # 限流 with grading_semaphore: # 限流
prompt = self._build_grading_prompt(answer) prompt = self._build_grading_prompt(answer)
response = self._call_llm(prompt) response = self._call_llm(prompt)
try:
return self._parse_grading_result(response, answer) return self._parse_grading_result(response, answer)
except (json.JSONDecodeError, ValueError, TypeError) as e:
# 解析失败抛异常 → 触发 @retry 重试
logger.warning(f"[主观题评分] 解析失败将重试: {e}, 原始响应前200字: {str(response)[:200]}")
raise
def _build_grading_prompt(self, answer: Dict) -> str: def _build_grading_prompt(self, answer: Dict) -> str:
"""构造评分 Prompt""" """构造评分 Prompt"""
question_content = answer.get('question_content', {}) question_content = answer.get('content', {})
scoring_points = question_content.get('data', {}).get('scoring_points', []) scoring_points = question_content.get('data', {}).get('scoring_points', [])
stem = question_content.get('stem', '')
reference_answer = question_content.get('answer', '')
# 如果缺少评分标准,在 prompt 中补充提示
scoring_section = ""
if scoring_points:
scoring_section = json.dumps(scoring_points, ensure_ascii=False, indent=2)
else:
scoring_section = "(未提供评分标准,请根据参考答案自行判断要点)"
return f"""请批阅以下简答题。 return f"""请批阅以下简答题。
## 题目 ## 题目
{question_content.get('stem', '')} {stem if stem else '(未提供题目)'}
## 参考答案 ## 参考答案
{question_content.get('answer', '')} {reference_answer if reference_answer else '(未提供参考答案)'}
## 评分标准 ## 评分标准
{json.dumps(scoring_points, ensure_ascii=False, indent=2)} {scoring_section}
## 学生答案 ## 学生答案
{answer.get('student_answer', '')} {answer.get('student_answer', '')}
@@ -319,19 +338,20 @@ class AnswerGrader:
{answer.get('max_score', 10)} {answer.get('max_score', 10)}
## 输出约束 ## 输出约束
1. 必须输出合法 JSON 1. 必须输出合法 JSON,不要包含任何占位符或中文说明
2. score 不能超过满分 2. score 为数字,不能超过满分
3. achieved 为 0-1 之间的比例 3. achieved 为 0-1 之间的数字
4. 所有字段必须填入实际评分值
## 输出格式JSON ## 输出格式示例JSON
{{ {{
"score": 得分, "score": 7,
"scoring_breakdown": [ "scoring_breakdown": [
{{"point": "要点名称", "weight": 权重, "achieved": 实际得分比例, "comment": "评语"}} {{"point": "核心概念正确", "weight": 0.5, "achieved": 0.8, "comment": "基本概念描述准确"}}
], ],
"highlights": ["亮点1", "亮点2"], "highlights": ["回答条理清晰"],
"shortcomings": ["不足1"], "shortcomings": ["缺少具体应用场景"],
"overall_feedback": "整体评价" "overall_feedback": "整体回答较好,但不够全面"
}} }}
请直接输出 JSON""" 请直接输出 JSON"""
@@ -357,35 +377,95 @@ class AnswerGrader:
raise Exception("LLM 调用失败") raise Exception("LLM 调用失败")
return result return result
def _extract_json(self, response: str) -> dict:
"""
多策略从 LLM 响应中提取 JSON 对象
策略优先级:
1. markdown 代码块提取 ```json ... ```
2. 直接 json.loads
3. 正则匹配第一个 {...} 块
"""
if not response:
raise ValueError("LLM 返回为空")
# 策略1提取 markdown 代码块
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', response)
if json_match:
json_str = json_match.group(1).strip()
try:
result = json.loads(json_str)
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass # 继续下一策略
# 策略2直接解析整个响应
try:
result = json.loads(response.strip())
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass # 继续下一策略
# 策略3正则匹配最外层 JSON 对象
brace_match = re.search(r'\{[\s\S]*\}', response)
if brace_match:
try:
result = json.loads(brace_match.group(0))
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
# 全部策略失败
raise json.JSONDecodeError(
f"无法从 LLM 响应中提取有效 JSON响应前300字: {response[:300]}",
response, 0
)
def _parse_grading_result(self, response: str, answer: Dict) -> Dict: def _parse_grading_result(self, response: str, answer: Dict) -> Dict:
"""解析评分结果""" """
解析评分结果
解析失败时抛出异常(由调用方的 @retry 处理重试)
"""
max_score = answer.get('max_score', 10) max_score = answer.get('max_score', 10)
try: # 检查主观题内容完整性
# 尝试解析 JSON question_content = answer.get('content', {})
result = json.loads(response) warnings = []
score = min(result.get('score', 0), max_score) # 不能超过满分 if not question_content.get('stem'):
warnings.append("缺少题目(stem)")
if not question_content.get('answer'):
warnings.append("缺少参考答案(answer)")
if not question_content.get('data', {}).get('scoring_points'):
warnings.append("缺少评分标准(scoring_points),评分结果仅供参考")
# 多策略提取 JSON失败抛异常 → 触发重试)
result = self._extract_json(response)
# 校验关键字段
score = result.get('score')
if score is None or not isinstance(score, (int, float)):
raise ValueError(f"score 字段缺失或类型错误: {score}")
score = min(float(score), max_score) # 不能超过满分
details = {
"scoring_breakdown": result.get('scoring_breakdown', []),
"highlights": result.get('highlights', []),
"shortcomings": result.get('shortcomings', []),
"overall_feedback": result.get('overall_feedback', '')
}
if warnings:
details["warnings"] = warnings
return { return {
"question_id": answer.get('question_id'), "question_id": answer.get('question_id'),
"score": score, "score": score,
"max_score": max_score, "max_score": max_score,
"grading_status": "success", "grading_status": "success",
"details": { "details": details
"scoring_breakdown": result.get('scoring_breakdown', []),
"highlights": result.get('highlights', []),
"shortcomings": result.get('shortcomings', []),
"overall_feedback": result.get('overall_feedback', '')
}
}
except (json.JSONDecodeError, KeyError, TypeError) as e:
# 解析失败,返回默认
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"grading_status": "failed",
"details": {"error": "评分结果解析失败"}
} }

View File

@@ -69,7 +69,8 @@ def generate_questions_from_file(
question_types: Dict[str, int], question_types: Dict[str, int],
difficulty: int = 3, difficulty: int = 3,
options: Dict = None, options: Dict = None,
request_id: str = None request_id: str = None,
exclude_stems: List[str] = None
) -> Dict: ) -> Dict:
""" """
从文件生成题目(结构化出题 v2 从文件生成题目(结构化出题 v2
@@ -88,6 +89,7 @@ def generate_questions_from_file(
options: 可选配置 options: 可选配置
- max_source_chunks: 最大切片数(默认 30 - max_source_chunks: 最大切片数(默认 30
request_id: 请求 ID幂等性支持 request_id: 请求 ID幂等性支持
exclude_stems: 需要排除的已有题目题干列表(跨调用去重)
Returns: Returns:
{ {
@@ -115,7 +117,8 @@ def generate_questions_from_file(
chunks=chunks, chunks=chunks,
document_name=file_path, document_name=file_path,
question_types=question_types, question_types=question_types,
difficulty=difficulty difficulty=difficulty,
exclude_stems=exclude_stems
) )
# 3. 统计实际出题数量,与请求数量对比 # 3. 统计实际出题数量,与请求数量对比