6 Commits

Author SHA1 Message Date
User
df33a912af fix(exam): 正确返回文件未索引错误 2026-07-19 14:25:40 +08:00
User
d7fb98faf6 feat: 添加文件向量化状态检查 2026-07-19 13:35:00 +08:00
User
2fbf1b85f9 docs: 更新出题批题后端对接指南 v1.2 — 同步实际行为
- 移除20题上限说明,改为无上限+50题警告
- 修正文件/向量库不存在时的响应格式(total=0,非404)
- 添加认证模式说明(生产模式直接放行)
- 添加智能出题接口文档(/exam/generate-smart)
- 添加填空题student_answer格式校验说明
- 更新错误码表,移除不存在的FILE_NOT_FOUND
- 添加Q5/Q6常见问题
- 响应示例修正status_code为2020
2026-07-03 12:40:47 +08:00
User
c709360c7c feat(server-release): 出题批阅业务逻辑优化 — 移除20题限制、max_total支持、填空题格式校验、修正认证状态码
- exam_pkg/api.py: 移除总题数20道上限,改为50道警告;修正认证错误使用正确的UNAUTHORIZED/FORBIDDEN状态码;端点保持同步模式
- exam_pkg/generator.py: AI智能出题prompt支持max_total参数
- exam_pkg/grader.py: 填空题增加学生答案格式校验(列表类型+字符串元素)
2026-07-03 12:03:46 +08:00
lacerate551
4a262728b1 fix(exam_pkg): 判断题返回bool + 填空题扁平数组归一化
grader.py:
- 判断题批阅结果 student_answer/correct_answer 统一返回 true/false (bool)
  兼容 "对"/"错"/"true"/"false"/True/False/1/0 等所有输入格式
- 填空题新增 _normalize_fill_blank_answer 兜底归一化
  修复 LLM 生成扁平数组 ["ans1","ans2"] 导致 1空多选项误判满分的bug
- feedback 文本统一用小写 true/false

generator.py:
- validate_questions_schema 出题时修正扁平数组为二维格式
  从源头防止填空题答案格式错误
2026-06-30 10:28:49 +08:00
lacerate551
dd213df0e2 fix(exam_pkg): v2补题去重与final已有题干交叉去重
补题阶段仅和exclude_stems去重,未检查final中已有题干,
可能补出重复题目。修复:收集final题干+exclude_stems合并传入
_deduplicate_questions,多题型补题时逐轮累积排除列表。
2026-06-22 19:07:08 +08:00
8 changed files with 620 additions and 41 deletions

View File

@@ -49,6 +49,7 @@ _STATUS_MESSAGES: Dict[int, str] = {
4011: "向量库不存在",
4012: "文件内容为空",
4013: "权限不足",
4016: "文件未向量化",
# 服务端错误 (50xx)
5000: "服务器内部错误",
@@ -113,6 +114,7 @@ FILE_NOT_FOUND = 4010
COLLECTION_NOT_FOUND = 4011
NO_CONTENT = 4012
PERMISSION_DENIED = 4013
FILE_NOT_INDEXED = 4016
# 服务端错误 (50xx)
INTERNAL_ERROR = 5000

View File

@@ -16,7 +16,7 @@ max_requests = 1000 # 每个worker处理1000个请求后重启防止内存
max_requests_jitter = 50
# 超时配置
timeout = 120 # 优化后不应超过2分钟原 300
timeout = 600 # 出题接口可能需要 5-10 分钟
graceful_timeout = 60
keepalive = 5

View File

@@ -4,10 +4,20 @@
| 接口 | 方法 | 功能 | 超时建议 |
|------|------|------|----------|
| `/exam/generate` | POST | 生成题目(手动指定题型数量) | 120秒 |
| `/exam/generate-smart` | POST | 生成题目AI 自动分析文档结构出题) | 120秒 |
| `/exam/generate` | POST | 生成题目(手动指定题型数量) | 读取超时至少 650 秒 |
| `/exam/generate-smart` | POST | 生成题目AI 自动分析文档结构出题) | 读取超时至少 650 秒 |
| `/exam/grade` | POST | 批阅答案 | 60秒 |
> **2026-07-19 变更**
> 1. 文件未完成向量化时不再返回“成功但 0 道题”,统一返回 HTTP 409、`FILE_NOT_INDEXED`、业务状态码 `4016`
> 2. `/exam/generate-smart` 会在 AI 分析前检查文件状态,未索引时不会调用模型
> 3. 正常成功响应保持不变Gunicorn 出题超时调整为 600 秒
>
> **2026-07-03 变更**
> 1. 移除出题总题数 20 道上限,超过 50 道时返回警告(不影响出题)
> 2. 填空题批阅增加 `student_answer` 格式校验(必须为字符串列表)
> 3. 认证错误状态码修正(仅 `DEV_MODE=true` 时生效,见 [认证说明](#认证模式说明)
>
> **2026-06-05 变更**`/exam/grade` 请求字段 `question_content` 已重命名为 `content`(破坏性变更)。详见 [出题批阅接口变更说明2026-06-05](出题批阅接口变更说明2026-06-05.md)。
---
@@ -27,9 +37,10 @@ Content-Type: application/json
|------|------|------|------|
| `file_path` | string | ✅ | 文档路径(相对于 documents 目录) |
| `collection` | string 或 string[] | ✅ | 向量库名称,支持数组(按优先级顺序检索,找到文件即停止) |
| `question_types` | object | ✅ | 题型及数量 |
| `question_types` | object | ✅ | 题型及数量**无上限**(超过 50 道时服务端返回警告但仍正常出题) |
| `difficulty` | int | ❌ | 难度等级 1-5默认 3 |
| `request_id` | string | ❌ | 请求ID相同ID返回缓存结果幂等性 |
| `request_id` | string | ❌ | 请求追踪 ID服务端原样返回当前不提供幂等缓存 |
| `exclude_stems` | string[] | ❌ | 排除已有题目的题干列表,避免重复出题(最多 100 条) |
**请求示例:**
@@ -45,7 +56,7 @@ Content-Type: application/json
"subjective": 1
},
"difficulty": 3,
"request_id": "uuid-for-idempotency"
"request_id": "uuid-for-tracing"
}
```
@@ -64,12 +75,15 @@ Content-Type: application/json
{
"success": true,
"status": "success",
"status_code": 2011,
"message": "出题成",
"status_code": 2020,
"message": "出题成",
"data": {
"request_id": "uuid-xxx",
"total": 10,
"source_chunks_used": 25,
"requested_types": {"single_choice": 3, "fill_blank": 2},
"actual_types": {"single_choice": 3, "fill_blank": 2},
"warnings": [],
"questions": [
{
"question_type": "single_choice",
@@ -89,7 +103,8 @@ Content-Type: application/json
},
"source_trace": {
"document_name": "薪酬制度.docx",
"chunks_count": 3
"page_numbers": [1],
"sources": [{"chunk_id": "...", "page": 1, "section": "...", "snippet": "..."}]
}
}
]
@@ -97,17 +112,29 @@ Content-Type: application/json
}
```
**失败响应:**
**⚠️ 文件未完成向量化时的响应:**
`file_path` 在指定 `collection` 中找不到时,两个出题接口都返回 HTTP 409
```json
{
"success": false,
"status": "failed",
"error_code": "FILE_NOT_FOUND",
"message": "文件不存在"
"error_code": "FILE_NOT_INDEXED",
"status_code": 4016,
"message": "文件未向量化: 文件 1.docx 未在向量库中找到,可能正在向量化或未上传",
"data": {
"request_id": "uuid-for-tracing",
"file_status": "not_found",
"chunk_count": 0
}
}
```
> **后端处理**:收到 HTTP 409 且 `error_code=FILE_NOT_INDEXED` 时,提示用户文件尚未完成向量化;不要把该响应当作成功题目入库,也不要立即高频重试。
>
> 当前版本要求后端传入有效的 `collection`。不存在的向量库暂时也可能表现为 `FILE_NOT_INDEXED`。
### 2.3 返回字段说明
**RAG 服务返回的字段(后端需存储):**
@@ -148,11 +175,64 @@ Content-Type: application/json
| 错误码 | HTTP | 说明 |
|--------|------|------|
| `FILE_NOT_FOUND` | 404 | 指定文件不存在 |
| `COLLECTION_NOT_FOUND` | 404 | 指定向量库不存在 |
| `NO_CONTENT` | 400 | 文件内容为空,无法出题 |
| `LLM_ERROR` | 500 | LLM 调用失败 |
| `PARSE_ERROR` | 500 | 解析失败 |
| `MISSING_PARAMS` | 400 | 缺少必填参数file_path / collection / question_types |
| `INVALID_PARAMS` | 400 | 参数校验失败题型无效、难度越界、总题数≤0 等) |
| `FILE_NOT_INDEXED` | 409 | 文件未完成向量化,业务状态码为 `4016` |
| `EXAM_ERROR` | 500 | 出题过程异常(LLM 调用失败、解析错误等) |
> **注意**:即使文件已索引,模型也可能少生成题目。成功响应仍应检查 `data.total` 和 `data.warnings`;部分生成属于成功响应,不应与 `FILE_NOT_INDEXED` 混淆。
### 2.6 智能出题接口(/exam/generate-smart
`/exam/generate` 的区别:不需要传 `question_types`AI 自动分析文档后决定题型和数量。
```
POST /exam/generate-smart
Content-Type: application/json
```
**请求体字段说明:**
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `file_path` | string | ✅ | 文档路径 |
| `collection` | string 或 string[] | ✅ | 向量库名称 |
| `difficulty` | int | ❌ | 难度等级 1-5默认 3 |
| `max_total` | int | ❌ | AI 出题总数上限,不传则不限制 |
| `exclude_stems` | string[] | ❌ | 排除已有题目的题干列表 |
| `request_id` | string | ❌ | 请求ID |
**请求示例:**
```json
{
"file_path": "111/卷烟货源组织管理办法.docx",
"collection": "111",
"difficulty": 3,
"max_total": 10,
"request_id": "smart-uuid-xxx"
}
```
**响应:** 成功时与 `/exam/generate` 格式相同,并额外包含 `ai_analysis`;文件未索引时同样返回 HTTP 409且不会执行 AI 分析。
---
### 认证模式说明
RAG 服务支持两种认证模式,由环境变量 `DEV_MODE` 控制:
| 模式 | DEV_MODE | 认证行为 |
|------|----------|----------|
| **生产模式** | `false` | 认证网关**直接放行**,不校验 Header。权限由后端服务完全控制 |
| **开发模式** | `true` | 支持 `Authorization: Bearer mock-token-admin` 模拟登录,无 Header 时使用默认测试用户 |
**当前服务器为生产模式**`DEV_MODE=false`),因此:
- `Authorization` Header 会被忽略
- 不会返回 `401 UNAUTHORIZED``403 FORBIDDEN`
- 后端服务应自行完成用户认证和权限校验,再调用 RAG 出题/批阅接口
> 开发模式下,认证错误会返回正确的业务状态码(`UNAUTHORIZED=4001`、`FORBIDDEN=4002`HTTP 状态码分别为 401/403。
---
@@ -182,6 +262,12 @@ Content-Type: application/json
| `student_answer` | any | ✅ | 学生答案 | 学生提交 |
| `max_score` | number | ✅ | 满分 | 后端数据库 |
> **填空题 `student_answer` 格式要求**2026-07-03 新增校验):
> - 必须为 **字符串数组**,如 `["答案1", "答案2"]`
> - 不是列表(如传了字符串 `"答案1"`)→ 该题直接得 0 分,`grading_status` 为 `"failed"`
> - 列表中某项不是字符串(如传了数字 `123`)→ 该题直接得 0 分
> - 校验失败不影响其他题目的正常批阅
**请求示例:**
```json
@@ -366,8 +452,11 @@ Content-Type: application/json
| 错误码 | HTTP | 说明 |
|--------|------|------|
| `INVALID_ANSWER_FORMAT` | 400 | 答案格式不正确 |
| `GRADING_ERROR` | 500 | 批阅过程出错 |
| `MISSING_PARAMS` | 400 | 缺少 answers 字段 |
| `INVALID_PARAMS` | 400 | answers 非数组、question_type 无效等 |
| `GRADE_ERROR` | 500 | 批阅过程出错 |
> **填空题格式错误不返回错误码**:当 `student_answer` 格式不合法时,该题返回 `grading_status: "failed"` + `score: 0`,但接口整体仍返回 `success: true`。后端应检查每道题的 `grading_status` 字段。
---
@@ -464,8 +553,23 @@ Content-Type: application/json
- 设置 60 秒超时
- 主观题较多时适当延长
### Q5: 出题数量有上限吗?
2026-07-03 起,出题接口**不再限制总题数**。服务端会在请求数超过 50 道时记录警告日志,但不影响出题。建议:
- 单次出题不超过 30 道LLM 生成耗时与题数成正比30 道约需 5-8 分钟)
- 超过 30 道建议分批调用
- 后端 HTTP 客户端读取超时设置为至少 650 秒
### Q6: 填空题学生答案传错了会怎样?
2026-07-03 起,填空题增加了格式校验:
- `student_answer` 不是列表 → 该题得 0 分,`grading_status = "failed"`
- 列表中某项不是字符串 → 该题得 0 分
- 校验失败不影响同一请求中其他题目的批阅
- 接口整体仍返回 `success: true`,需检查每道题的 `grading_status`
---
**文档版本**: v1.1
**更新时间**: 2026-05-17
**文档版本**: v1.3
**更新时间**: 2026-07-19
**相关文档**: [后端对接规范.md](./后端对接规范.md)

View File

@@ -19,6 +19,7 @@ import os
# 导入考试管理模块
from exam_pkg.manager import (
check_file_indexed,
generate_questions_from_file,
grade_answers,
)
@@ -29,7 +30,7 @@ from auth.gateway import (
)
# 导入统一响应格式
from core.status_codes import EXAM_SUCCESS, GRADE_SUCCESS, EXAM_ERROR, GRADE_ERROR, BAD_REQUEST, NO_CONTENT, LLM_ERROR
from core.status_codes import EXAM_SUCCESS, GRADE_SUCCESS, EXAM_ERROR, GRADE_ERROR, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NO_CONTENT, LLM_ERROR, FILE_NOT_INDEXED
from api.response_utils import success_response, error_response
# 合法题型
@@ -81,6 +82,38 @@ def validate_exclude_stems(exclude_stems) -> str:
exam_bp = Blueprint('exam', __name__)
def _generation_failure_response(
error_code: str,
message: str,
request_id=None,
file_status=None,
chunk_count: int = 0,
):
"""将出题内部失败统一转换为 HTTP 业务错误。"""
data = {
"request_id": request_id,
"file_status": file_status,
"chunk_count": chunk_count,
}
if error_code == "FILE_NOT_INDEXED":
return error_response(
"FILE_NOT_INDEXED",
FILE_NOT_INDEXED,
message or "文件尚未完成向量化",
http_status=409,
data=data,
)
return error_response(
"EXAM_ERROR",
EXAM_ERROR,
message or "检查文件状态失败",
http_status=500,
data=data,
)
# ==================== 出题 API ====================
@exam_bp.route('/generate', methods=['POST'])
@@ -152,13 +185,14 @@ def api_generate_questions():
if diff_error:
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:
if total_requested <= 0:
return error_response("INVALID_PARAMS", BAD_REQUEST,
f"总题数不能超过 {MAX_TOTAL_QUESTIONS} 道,当前请求 {total_requested}",
"总题数必须大于0",
http_status=400)
if total_requested > 50:
logger.warning(f"[出题] 请求生成 {total_requested} 道题,可能影响性能")
# 校验排除题干列表(可选)
exclude_stems = data.get('exclude_stems')
@@ -169,7 +203,7 @@ def api_generate_questions():
# 获取当前用户
user = get_current_user()
if not user:
return error_response("UNAUTHORIZED", BAD_REQUEST, "未认证", http_status=401)
return error_response("UNAUTHORIZED", UNAUTHORIZED, "未认证", http_status=401)
# 检查向量库访问权限
if not check_collection_permission(
@@ -178,9 +212,9 @@ def api_generate_questions():
collection_name=collection,
operation="read"
):
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
# 调用新版出题接口
# 调用新版出题接口(同步)
result = generate_questions_from_file(
file_path=file_path,
collection=collection,
@@ -191,6 +225,15 @@ def api_generate_questions():
exclude_stems=data.get('exclude_stems')
)
if not result.get('success', True):
return _generation_failure_response(
result.get('error_code'),
result.get('message'),
request_id=result.get('request_id'),
file_status=result.get('file_status'),
chunk_count=result.get('chunk_count', 0),
)
return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功")
except Exception as e:
@@ -251,7 +294,7 @@ def api_generate_smart():
# 获取当前用户
user = get_current_user()
if not user:
return error_response("UNAUTHORIZED", BAD_REQUEST, "未认证", http_status=401)
return error_response("UNAUTHORIZED", UNAUTHORIZED, "未认证", http_status=401)
# 检查向量库访问权限
if not check_collection_permission(
@@ -260,7 +303,23 @@ def api_generate_smart():
collection_name=collection,
operation="read"
):
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
# 智能出题必须在 AI 分析前确认文件已完成索引,避免无效模型调用。
file_status = check_file_indexed(file_path, collection)
if not file_status.get('indexed'):
is_not_indexed = file_status.get('status') == 'not_found'
return _generation_failure_response(
"FILE_NOT_INDEXED" if is_not_indexed else "STATUS_CHECK_ERROR",
(
f"文件未向量化: {file_status.get('message', '')}"
if is_not_indexed
else f"检查文件状态失败: {file_status.get('message', '')}"
),
request_id=data.get('request_id'),
file_status=file_status.get('status'),
chunk_count=file_status.get('chunk_count', 0),
)
# 1. 调用 AI 分析文件,获取推荐的题型和数量
from exam_pkg.manager import analyze_file_for_exam
@@ -285,6 +344,15 @@ def api_generate_smart():
exclude_stems=data.get('exclude_stems')
)
if not result.get('success', True):
return _generation_failure_response(
result.get('error_code'),
result.get('message'),
request_id=result.get('request_id'),
file_status=result.get('file_status'),
chunk_count=result.get('chunk_count', 0),
)
# 3. 在返回结果中添加 AI 分析信息
result['ai_analysis'] = ai_analysis

View File

@@ -169,6 +169,14 @@ def validate_questions_schema(questions: List[Dict]) -> List[Dict]:
if not content.get('data', {}).get('options'):
continue
# 填空题答案格式归一化:扁平数组 → 二维数组
if q_type == 'fill_blank':
ans = content.get('answer')
if isinstance(ans, list) and ans and all(isinstance(item, str) for item in ans):
# 扁平数组 ["答案1", "答案2"] → [["答案1"], ["答案2"]]
content['answer'] = [[item] for item in ans]
logger.warning(f"填空题答案格式修正: 扁平数组 → 二维数组 ({len(ans)} 空)")
validated.append(q)
return validated
@@ -1047,7 +1055,7 @@ def analyze_document_for_exam(chunks: List[Dict], max_total: int = None) -> Dict
注意:
- 不适合的题型数量设为 0
- 所有数量之和不要超过 {min(total_knowledge_points * 2, 20)}
- 所有数量之和不要超过 {min(total_knowledge_points * 2, max_total if max_total else 20)}
- 必须返回合法 JSON不要有其他内容
请直接输出 JSON"""
@@ -1300,12 +1308,21 @@ def generate_questions_structured_v2(
if shortage_types:
logger.info(f" [v2] 补题: 缺少题型 {dict(shortage_types)}")
# 收集 final 中已有题干,补题时一并排除
existing_stems = [q.get('content', {}).get('stem', '') for q in final if q.get('content', {}).get('stem')]
combined_exclude = list(exclude_stems or []) + existing_stems
for q_type, shortage in shortage_types.items():
logger.info(f" 补充 {q_type} {shortage} 道...")
extra = generator._makeup_questions(chunks, q_type, shortage, difficulty, document_name)
# 补题需去重
extra_deduped = _deduplicate_questions(extra, exclude_stems=exclude_stems)
# 补题需与已有题目 + 跨调用排除列表一起去重
extra_deduped = _deduplicate_questions(extra, exclude_stems=combined_exclude)
final.extend(extra_deduped[:shortage])
# 将新补的题干也加入排除列表,防止后续题型补出重复
for q in extra_deduped:
stem = q.get('content', {}).get('stem', '')
if stem:
combined_exclude.append(stem)
# 补题后重新统计
type_counts = defaultdict(int)

View File

@@ -88,11 +88,33 @@ grading_semaphore = threading.Semaphore(MAX_CONCURRENT_GRADING)
# ==================== 本地批阅函数 ====================
def _normalize_true_false(value) -> bool:
"""
将判断题的各种表示形式统一转为 bool。
支持: ""/"", "正确"/"错误", "true"/"false", "yes"/"no",
True/False, 1/0, "T"/"F", "1"/"0"
"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
v = value.strip().lower()
if v in ('', '正确', 'true', 'yes', 't', '1'):
return True
if v in ('', '错误', 'false', 'no', 'f', '0'):
return False
# 无法识别时返回 None让比较逻辑走原始字符串匹配
return None
def grade_objective(answer: Dict) -> Dict:
"""
批阅客观题(选择/判断)
🔥 本地直接判断,无 LLM 调用
判断题返回的 student_answer / correct_answer 统一为 bool (true/false)
"""
q_type = answer['question_type']
question_content = answer.get('content', {})
@@ -100,17 +122,39 @@ def grade_objective(answer: Dict) -> Dict:
student_answer = answer.get('student_answer')
max_score = answer.get('max_score', 2.0)
# 判断正确性
if q_type == 'single_choice':
# 判断题:归一化为 bool 比较 + bool 输出
if q_type == 'true_false':
norm_correct = _normalize_true_false(correct_answer)
norm_student = _normalize_true_false(student_answer)
if norm_correct is not None and norm_student is not None:
correct = norm_correct == norm_student
correct_answer = norm_correct
student_answer = norm_student
else:
# 兜底:无法归一化时用原始字符串比较
correct = student_answer == correct_answer
# 仍尝试转为 bool 输出,转不了则保留原值
if norm_correct is not None:
correct_answer = norm_correct
if norm_student is not None:
student_answer = norm_student
elif q_type == 'single_choice':
correct = student_answer == correct_answer
elif q_type == 'multiple_choice':
# 多选题:答案顺序无关
correct = set(student_answer) == set(correct_answer) if isinstance(student_answer, list) else False
elif q_type == 'true_false':
correct = student_answer == correct_answer
else:
correct = False
# 构造 feedback判断题用 true/false其他题型用原值
if not correct:
if q_type == 'true_false' and isinstance(correct_answer, bool):
feedback = f"正确答案: {'true' if correct_answer else 'false'}"
else:
feedback = f"正确答案: {correct_answer}"
else:
feedback = "正确!"
return {
"question_id": answer.get('question_id'),
"score": max_score if correct else 0,
@@ -120,11 +164,49 @@ def grade_objective(answer: Dict) -> Dict:
"correct": correct,
"student_answer": student_answer,
"correct_answer": correct_answer,
"feedback": f"正确答案: {correct_answer}" if not correct else "正确!"
"feedback": feedback
}
}
def _normalize_fill_blank_answer(correct_answers: list, blank_count: int = 0) -> list:
"""
归一化填空题答案为二维数组 [["答案1", "同义词"], ["答案2"], ...]
修复 LLM 生成扁平数组 ["答案1", "答案2"] 的格式错误:
- 扁平数组会被误认为"1个空、多个可选答案",导致匹配一个就给满分
- 归一化后每个元素独立为空,各自占分
Args:
correct_answers: 原始答案(可能是 1D 或 2D
blank_count: 题目声明的空数(来自 content.data.blank_count用于辅助判断
"""
if not correct_answers:
return correct_answers
# 已经是标准二维格式:每个元素都是 list
if all(isinstance(item, list) for item in correct_answers):
return correct_answers
# 扁平数组:元素全是字符串 → 每个字符串是独立的空
if all(isinstance(item, str) for item in correct_answers):
expected_blanks = blank_count if blank_count > 0 else len(correct_answers)
logger.warning(
f"填空题答案格式修正: 扁平数组 {correct_answers!r} → 二维数组 "
f"(检测到 {len(correct_answers)} 个元素, blank_count={blank_count})"
)
return [[item] for item in correct_answers]
# 混合类型(不太可能发生),尝试兜底
result = []
for item in correct_answers:
if isinstance(item, list):
result.append(item)
else:
result.append([item])
return result
def grade_fill_blank(answer: Dict) -> Dict:
"""
批阅填空题 - 支持同义词匹配
@@ -137,6 +219,33 @@ def grade_fill_blank(answer: Dict) -> Dict:
student_answers = answer.get('student_answer', [])
max_score = answer.get('max_score', 4.0)
# 校验学生答案格式(必须是列表)
if not isinstance(student_answers, list):
logger.warning(f"填空题学生答案格式错误: 期望列表,实际为 {type(student_answers).__name__}: {student_answers}")
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"grading_status": "failed",
"details": {"error": f"学生答案格式错误,期望列表,实际为 {type(student_answers).__name__}"}
}
# 校验学生答案列表中的每个元素必须是字符串
for i, ans in enumerate(student_answers):
if not isinstance(ans, str):
logger.warning(f"填空题学生答案第 {i+1} 项格式错误: 期望字符串,实际为 {type(ans).__name__}: {ans}")
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"grading_status": "failed",
"details": {"error": f"填空题学生答案第 {i+1} 项格式错误,期望字符串,实际为 {type(ans).__name__}"}
}
# 归一化答案格式(修复 LLM 生成的扁平数组问题)
blank_count = question_content.get('data', {}).get('blank_count', 0)
correct_answers = _normalize_fill_blank_answer(correct_answers, blank_count)
if not correct_answers or not student_answers:
return {
"question_id": answer.get('question_id'),

View File

@@ -61,6 +61,113 @@ QUESTION_BANK_DIR = "./题库"
DRAFT_DIR = "./题库/草稿"
# ==================== 文件状态检查 ====================
def check_file_indexed(file_path: str, collection: str) -> Dict:
"""
检查文件是否已向量化
Args:
file_path: 文件路径
collection: 向量库名称
Returns:
{
"indexed": True/False, # 是否已索引
"chunk_count": 0, # 切片数量
"status": "ready/not_found/indexing/error",
"message": "状态说明"
}
"""
if not file_path or not collection:
return {
"indexed": False,
"chunk_count": 0,
"status": "error",
"message": "缺少文件路径或向量库名称"
}
try:
from core.engine import get_engine
engine = get_engine()
if not engine:
return {
"indexed": False,
"chunk_count": 0,
"status": "error",
"message": "RAG 引擎未初始化"
}
# 提取文件名
filename = os.path.basename(file_path.replace("\\", "/"))
# 查询向量库
collections = [collection] if isinstance(collection, str) else list(collection) if collection else []
if not collections:
return {
"indexed": False,
"chunk_count": 0,
"status": "error",
"message": "未指定向量库"
}
# 尝试检索文件切片
total_chunks = 0
found_collection = None
for coll in collections:
try:
# 使用简单 query 查询文件
results = engine.search_knowledge(
query="document",
collections=[coll],
source_filter=filename,
top_k=1
)
if results and results.get('documents') and results['documents'][0]:
# 获取该文件的切片总数
collection_obj = engine.kb_manager.get_collection(coll)
if collection_obj:
all_results = collection_obj.get(
where={"source": filename}
)
if all_results and all_results.get('ids'):
total_chunks = len(all_results['ids'])
found_collection = coll
break
except Exception as e:
logger.warning(f"检查向量库 {coll} 失败: {e}")
continue
if total_chunks > 0:
return {
"indexed": True,
"chunk_count": total_chunks,
"status": "ready",
"message": f"文件已索引,共 {total_chunks} 个切片",
"collection": found_collection
}
else:
return {
"indexed": False,
"chunk_count": 0,
"status": "not_found",
"message": f"文件 {filename} 未在向量库中找到,可能正在向量化或未上传"
}
except Exception as e:
logger.error(f"检查文件索引状态失败: {e}")
return {
"indexed": False,
"chunk_count": 0,
"status": "error",
"message": f"检查状态异常: {str(e)}"
}
# ==================== 新版出题接口 ====================
def generate_questions_from_file(
@@ -102,6 +209,28 @@ def generate_questions_from_file(
"""
options = options or {}
# 🔥 新增:检查文件向量化状态
file_status = check_file_indexed(file_path, collection)
if not file_status["indexed"]:
# 文件未索引,返回明确的错误信息
error_messages = {
"not_found": f"文件未向量化: {file_status['message']}",
"error": f"检查文件状态失败: {file_status['message']}",
}
return {
"success": False,
"request_id": request_id,
"error_code": "FILE_NOT_INDEXED" if file_status["status"] == "not_found" else "STATUS_CHECK_ERROR",
"message": error_messages.get(file_status["status"], file_status["message"]),
"file_status": file_status["status"],
"chunk_count": file_status["chunk_count"],
"questions": [],
"total": 0,
"source_chunks_used": 0
}
# 1. 检索文件的所有切片
chunks = retrieve_file_chunks(
file_path=file_path,
@@ -227,7 +356,7 @@ def retrieve_file_chunks_for_analysis(
collections = [collection] if isinstance(collection, str) else collection
# 提取文件名(向量库存储的是文件名,不含路径前缀)
filename = os.path.basename(file_path)
filename = os.path.basename(file_path.replace("\\", "/"))
# 向量检索(尝试文件名和完整路径)
results = None
@@ -295,7 +424,7 @@ def retrieve_file_chunks(
query = build_semantic_query(question_types)
# 提取文件名(向量库存储的是文件名,不含路径前缀)
filename = os.path.basename(file_path)
filename = os.path.basename(file_path.replace("\\", "/"))
# 统一处理为列表
if isinstance(collection, str):

View File

@@ -0,0 +1,150 @@
import unittest
from unittest.mock import MagicMock, patch
from flask import Flask
from exam_pkg.api import exam_bp
from exam_pkg.manager import check_file_indexed
class ExamFileStatusApiTest(unittest.TestCase):
def setUp(self):
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(exam_bp, url_prefix="/exam")
self.client = app.test_client()
@patch("exam_pkg.api.generate_questions_from_file")
def test_generate_returns_409_when_file_is_not_indexed(self, generate):
generate.return_value = {
"success": False,
"request_id": "req-1",
"error_code": "FILE_NOT_INDEXED",
"message": "文件未向量化",
"file_status": "not_found",
"chunk_count": 0,
"questions": [],
"total": 0,
"source_chunks_used": 0,
}
response = self.client.post(
"/exam/generate",
json={
"file_path": "missing.pdf",
"collection": "public_kb",
"question_types": {"single_choice": 1},
"request_id": "req-1",
},
)
self.assertEqual(response.status_code, 409)
body = response.get_json()
self.assertFalse(body["success"])
self.assertEqual(body["error_code"], "FILE_NOT_INDEXED")
self.assertEqual(body["status_code"], 4016)
self.assertEqual(body["data"]["request_id"], "req-1")
@patch("exam_pkg.manager.analyze_file_for_exam")
@patch("exam_pkg.api.check_file_indexed")
def test_generate_smart_stops_before_ai_analysis(self, check_status, analyze):
check_status.return_value = {
"indexed": False,
"chunk_count": 0,
"status": "not_found",
"message": "文件未找到",
}
response = self.client.post(
"/exam/generate-smart",
json={
"file_path": "missing.pdf",
"collection": "public_kb",
"request_id": "req-smart",
},
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.get_json()["status_code"], 4016)
analyze.assert_not_called()
@patch("exam_pkg.api.generate_questions_from_file")
def test_generate_success_response_is_unchanged(self, generate):
generate.return_value = {
"success": True,
"request_id": "req-ok",
"questions": [{"question_type": "single_choice"}],
"total": 1,
"source_chunks_used": 1,
}
response = self.client.post(
"/exam/generate",
json={
"file_path": "ready.pdf",
"collection": "public_kb",
"question_types": {"single_choice": 1},
},
)
self.assertEqual(response.status_code, 200)
body = response.get_json()
self.assertTrue(body["success"])
self.assertEqual(body["status_code"], 2020)
self.assertTrue(body["data"]["success"])
@patch("exam_pkg.api.generate_questions_from_file")
def test_status_check_error_uses_existing_exam_error(self, generate):
generate.return_value = {
"success": False,
"request_id": "req-error",
"error_code": "STATUS_CHECK_ERROR",
"message": "检查状态异常",
"file_status": "error",
"chunk_count": 0,
}
response = self.client.post(
"/exam/generate",
json={
"file_path": "document.pdf",
"collection": "public_kb",
"question_types": {"single_choice": 1},
},
)
self.assertEqual(response.status_code, 500)
body = response.get_json()
self.assertEqual(body["error_code"], "EXAM_ERROR")
self.assertEqual(body["status_code"], 5020)
class FilePathNormalizationTest(unittest.TestCase):
@patch("core.engine.get_engine")
def test_windows_path_is_matched_by_filename(self, get_engine):
collection = MagicMock()
collection.get.return_value = {"ids": ["chunk-1"]}
engine = MagicMock()
engine.search_knowledge.return_value = {
"documents": [["content"]],
}
engine.kb_manager.get_collection.return_value = collection
get_engine.return_value = engine
result = check_file_indexed(
r"public_kb\\folder\\ready.pdf",
"public_kb",
)
self.assertTrue(result["indexed"])
engine.search_knowledge.assert_called_once_with(
query="document",
collections=["public_kb"],
source_filter="ready.pdf",
top_k=1,
)
collection.get.assert_called_once_with(where={"source": "ready.pdf"})
if __name__ == "__main__":
unittest.main()