fix(exam): 正确返回文件未索引错误

This commit is contained in:
User
2026-07-19 14:25:40 +08:00
parent d7fb98faf6
commit df33a912af
6 changed files with 252 additions and 28 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,15 @@
| 接口 | 方法 | 功能 | 超时建议 |
|------|------|------|----------|
| `/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` 格式校验(必须为字符串列表)
@@ -34,7 +39,7 @@ Content-Type: application/json
| `collection` | string 或 string[] | ✅ | 向量库名称,支持数组(按优先级顺序检索,找到文件即停止) |
| `question_types` | object | ✅ | 题型及数量,**无上限**(超过 50 道时服务端返回警告但仍正常出题) |
| `difficulty` | int | ❌ | 难度等级 1-5默认 3 |
| `request_id` | string | ❌ | 请求ID相同ID返回缓存结果幂等性 |
| `request_id` | string | ❌ | 请求追踪 ID服务端原样返回当前不提供幂等缓存 |
| `exclude_stems` | string[] | ❌ | 排除已有题目的题干列表,避免重复出题(最多 100 条) |
**请求示例:**
@@ -51,7 +56,7 @@ Content-Type: application/json
"subjective": 1
},
"difficulty": 3,
"request_id": "uuid-for-idempotency"
"request_id": "uuid-for-tracing"
}
```
@@ -107,29 +112,28 @@ Content-Type: application/json
}
```
**⚠️ 文件/向量库不存在时的响应:**
**⚠️ 文件未完成向量化时的响应:**
`file_path` 在指定 `collection` 中找不到,或 `collection` 不存在时,接口**不会返回错误**,而是返回空结果
`file_path` 在指定 `collection` 中找不到时,两个出题接口都返回 HTTP 409
```json
{
"success": true,
"status": "success",
"status_code": 2020,
"message": "出题成功",
"success": false,
"status": "failed",
"error_code": "FILE_NOT_INDEXED",
"status_code": 4016,
"message": "文件未向量化: 文件 1.docx 未在向量库中找到,可能正在向量化或未上传",
"data": {
"request_id": null,
"total": 0,
"questions": [],
"requested_types": {"single_choice": 1},
"actual_types": {},
"warnings": ["single_choice: 请求 1 道,实际生成 0 道"]
"request_id": "uuid-for-tracing",
"file_status": "not_found",
"chunk_count": 0
}
}
```
> **后端注意**必须检查 `data.total` 是否 > 0 来判断出题是否成功,不能仅依赖 `success` 字段
```
> **后端处理**收到 HTTP 409 且 `error_code=FILE_NOT_INDEXED` 时,提示用户文件尚未完成向量化;不要把该响应当作成功题目入库,也不要立即高频重试
>
> 当前版本要求后端传入有效的 `collection`。不存在的向量库暂时也可能表现为 `FILE_NOT_INDEXED`。
### 2.3 返回字段说明
@@ -173,9 +177,10 @@ Content-Type: application/json
|--------|------|------|
| `MISSING_PARAMS` | 400 | 缺少必填参数file_path / collection / question_types |
| `INVALID_PARAMS` | 400 | 参数校验失败题型无效、难度越界、总题数≤0 等) |
| `FILE_NOT_INDEXED` | 409 | 文件未完成向量化,业务状态码为 `4016` |
| `EXAM_ERROR` | 500 | 出题过程异常LLM 调用失败、解析错误等) |
> **注意**文件不存在或向量库不存在时,不会返回错误码,而是返回 `success=true` + `total=0` + `warnings`(见上方说明)。后端应检查 `total > 0`
> **注意**即使文件已索引,模型也可能少生成题目。成功响应仍应检查 `data.total` `data.warnings`;部分生成属于成功响应,不应与 `FILE_NOT_INDEXED` 混淆
### 2.6 智能出题接口(/exam/generate-smart
@@ -209,7 +214,7 @@ Content-Type: application/json
}
```
**响应:**`/exam/generate` 格式相同,额外包含 `ai_analysis` 字段(AI 分析结果)
**响应:** 成功时`/exam/generate` 格式相同,额外包含 `ai_analysis`;文件未索引时同样返回 HTTP 409且不会执行 AI 分析。
---
@@ -553,7 +558,7 @@ Content-Type: application/json
2026-07-03 起,出题接口**不再限制总题数**。服务端会在请求数超过 50 道时记录警告日志,但不影响出题。建议:
- 单次出题不超过 30 道LLM 生成耗时与题数成正比30 道约需 5-8 分钟)
- 超过 30 道建议分批调用
- 设置 120 秒以上超时
- 后端 HTTP 客户端读取超时设置为至少 650 秒
### Q6: 填空题学生答案传错了会怎样?
@@ -565,6 +570,6 @@ Content-Type: application/json
---
**文档版本**: v1.2
**更新时间**: 2026-07-03
**文档版本**: 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, UNAUTHORIZED, FORBIDDEN, 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'])
@@ -192,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:
@@ -263,6 +305,22 @@ def api_generate_smart():
):
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
ai_analysis = analyze_file_for_exam(
@@ -286,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

@@ -100,7 +100,7 @@ def check_file_indexed(file_path: str, collection: str) -> Dict:
}
# 提取文件名
filename = os.path.basename(file_path)
filename = os.path.basename(file_path.replace("\\", "/"))
# 查询向量库
collections = [collection] if isinstance(collection, str) else list(collection) if collection else []
@@ -356,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
@@ -424,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()