From df33a912afe7d3c20c86f6d734cf469e5dfaab15 Mon Sep 17 00:00:00 2001 From: User Date: Sun, 19 Jul 2026 14:25:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(exam):=20=E6=AD=A3=E7=A1=AE=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E6=96=87=E4=BB=B6=E6=9C=AA=E7=B4=A2=E5=BC=95=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/status_codes.py | 2 + deploy/gunicorn.conf.py | 2 +- docs/出题批题后端对接指南.md | 51 ++++---- exam_pkg/api.py | 69 ++++++++++- exam_pkg/manager.py | 6 +- tests/test_exam_file_status_response.py | 150 ++++++++++++++++++++++++ 6 files changed, 252 insertions(+), 28 deletions(-) create mode 100644 tests/test_exam_file_status_response.py diff --git a/core/status_codes.py b/core/status_codes.py index 91bd265..13e9a32 100644 --- a/core/status_codes.py +++ b/core/status_codes.py @@ -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 diff --git a/deploy/gunicorn.conf.py b/deploy/gunicorn.conf.py index 7620d1c..cba46f5 100644 --- a/deploy/gunicorn.conf.py +++ b/deploy/gunicorn.conf.py @@ -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 diff --git a/docs/出题批题后端对接指南.md b/docs/出题批题后端对接指南.md index 5134d0b..b045660 100644 --- a/docs/出题批题后端对接指南.md +++ b/docs/出题批题后端对接指南.md @@ -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) diff --git a/exam_pkg/api.py b/exam_pkg/api.py index 623c90d..e6cc054 100644 --- a/exam_pkg/api.py +++ b/exam_pkg/api.py @@ -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 diff --git a/exam_pkg/manager.py b/exam_pkg/manager.py index 1f0e80d..3db4caa 100644 --- a/exam_pkg/manager.py +++ b/exam_pkg/manager.py @@ -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): diff --git a/tests/test_exam_file_status_response.py b/tests/test_exam_file_status_response.py new file mode 100644 index 0000000..e551a74 --- /dev/null +++ b/tests/test_exam_file_status_response.py @@ -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()