fix(exam): 正确返回文件未索引错误
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
150
tests/test_exam_file_status_response.py
Normal file
150
tests/test_exam_file_status_response.py
Normal 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()
|
||||
Reference in New Issue
Block a user