Compare commits
6 Commits
5ccfdaf9d1
...
server-rel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df33a912af | ||
|
|
d7fb98faf6 | ||
|
|
2fbf1b85f9 | ||
|
|
c709360c7c | ||
|
|
4a262728b1 | ||
|
|
dd213df0e2 |
@@ -49,6 +49,7 @@ _STATUS_MESSAGES: Dict[int, str] = {
|
|||||||
4011: "向量库不存在",
|
4011: "向量库不存在",
|
||||||
4012: "文件内容为空",
|
4012: "文件内容为空",
|
||||||
4013: "权限不足",
|
4013: "权限不足",
|
||||||
|
4016: "文件未向量化",
|
||||||
|
|
||||||
# 服务端错误 (50xx)
|
# 服务端错误 (50xx)
|
||||||
5000: "服务器内部错误",
|
5000: "服务器内部错误",
|
||||||
@@ -113,6 +114,7 @@ FILE_NOT_FOUND = 4010
|
|||||||
COLLECTION_NOT_FOUND = 4011
|
COLLECTION_NOT_FOUND = 4011
|
||||||
NO_CONTENT = 4012
|
NO_CONTENT = 4012
|
||||||
PERMISSION_DENIED = 4013
|
PERMISSION_DENIED = 4013
|
||||||
|
FILE_NOT_INDEXED = 4016
|
||||||
|
|
||||||
# 服务端错误 (50xx)
|
# 服务端错误 (50xx)
|
||||||
INTERNAL_ERROR = 5000
|
INTERNAL_ERROR = 5000
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ max_requests = 1000 # 每个worker处理1000个请求后重启(防止内存
|
|||||||
max_requests_jitter = 50
|
max_requests_jitter = 50
|
||||||
|
|
||||||
# 超时配置
|
# 超时配置
|
||||||
timeout = 120 # 优化后不应超过2分钟(原 300)
|
timeout = 600 # 出题接口可能需要 5-10 分钟
|
||||||
graceful_timeout = 60
|
graceful_timeout = 60
|
||||||
keepalive = 5
|
keepalive = 5
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,20 @@
|
|||||||
|
|
||||||
| 接口 | 方法 | 功能 | 超时建议 |
|
| 接口 | 方法 | 功能 | 超时建议 |
|
||||||
|------|------|------|----------|
|
|------|------|------|----------|
|
||||||
| `/exam/generate` | POST | 生成题目(手动指定题型数量) | 120秒 |
|
| `/exam/generate` | POST | 生成题目(手动指定题型数量) | 读取超时至少 650 秒 |
|
||||||
| `/exam/generate-smart` | POST | 生成题目(AI 自动分析文档结构出题) | 120秒 |
|
| `/exam/generate-smart` | POST | 生成题目(AI 自动分析文档结构出题) | 读取超时至少 650 秒 |
|
||||||
| `/exam/grade` | POST | 批阅答案 | 60秒 |
|
| `/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)。
|
> **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 目录) |
|
| `file_path` | string | ✅ | 文档路径(相对于 documents 目录) |
|
||||||
| `collection` | string 或 string[] | ✅ | 向量库名称,支持数组(按优先级顺序检索,找到文件即停止) |
|
| `collection` | string 或 string[] | ✅ | 向量库名称,支持数组(按优先级顺序检索,找到文件即停止) |
|
||||||
| `question_types` | object | ✅ | 题型及数量 |
|
| `question_types` | object | ✅ | 题型及数量,**无上限**(超过 50 道时服务端返回警告但仍正常出题) |
|
||||||
| `difficulty` | int | ❌ | 难度等级 1-5,默认 3 |
|
| `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
|
"subjective": 1
|
||||||
},
|
},
|
||||||
"difficulty": 3,
|
"difficulty": 3,
|
||||||
"request_id": "uuid-for-idempotency"
|
"request_id": "uuid-for-tracing"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,12 +75,15 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"status_code": 2011,
|
"status_code": 2020,
|
||||||
"message": "出题完成",
|
"message": "出题成功",
|
||||||
"data": {
|
"data": {
|
||||||
"request_id": "uuid-xxx",
|
"request_id": "uuid-xxx",
|
||||||
"total": 10,
|
"total": 10,
|
||||||
"source_chunks_used": 25,
|
"source_chunks_used": 25,
|
||||||
|
"requested_types": {"single_choice": 3, "fill_blank": 2},
|
||||||
|
"actual_types": {"single_choice": 3, "fill_blank": 2},
|
||||||
|
"warnings": [],
|
||||||
"questions": [
|
"questions": [
|
||||||
{
|
{
|
||||||
"question_type": "single_choice",
|
"question_type": "single_choice",
|
||||||
@@ -89,7 +103,8 @@ Content-Type: application/json
|
|||||||
},
|
},
|
||||||
"source_trace": {
|
"source_trace": {
|
||||||
"document_name": "薪酬制度.docx",
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"error_code": "FILE_NOT_FOUND",
|
"error_code": "FILE_NOT_INDEXED",
|
||||||
"message": "文件不存在"
|
"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 返回字段说明
|
### 2.3 返回字段说明
|
||||||
|
|
||||||
**RAG 服务返回的字段(后端需存储):**
|
**RAG 服务返回的字段(后端需存储):**
|
||||||
@@ -148,11 +175,64 @@ Content-Type: application/json
|
|||||||
|
|
||||||
| 错误码 | HTTP | 说明 |
|
| 错误码 | HTTP | 说明 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `FILE_NOT_FOUND` | 404 | 指定文件不存在 |
|
| `MISSING_PARAMS` | 400 | 缺少必填参数(file_path / collection / question_types) |
|
||||||
| `COLLECTION_NOT_FOUND` | 404 | 指定向量库不存在 |
|
| `INVALID_PARAMS` | 400 | 参数校验失败(题型无效、难度越界、总题数≤0 等) |
|
||||||
| `NO_CONTENT` | 400 | 文件内容为空,无法出题 |
|
| `FILE_NOT_INDEXED` | 409 | 文件未完成向量化,业务状态码为 `4016` |
|
||||||
| `LLM_ERROR` | 500 | LLM 调用失败 |
|
| `EXAM_ERROR` | 500 | 出题过程异常(LLM 调用失败、解析错误等) |
|
||||||
| `PARSE_ERROR` | 500 | 解析失败 |
|
|
||||||
|
> **注意**:即使文件已索引,模型也可能少生成题目。成功响应仍应检查 `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 | ✅ | 学生答案 | 学生提交 |
|
| `student_answer` | any | ✅ | 学生答案 | 学生提交 |
|
||||||
| `max_score` | number | ✅ | 满分 | 后端数据库 |
|
| `max_score` | number | ✅ | 满分 | 后端数据库 |
|
||||||
|
|
||||||
|
> **填空题 `student_answer` 格式要求**(2026-07-03 新增校验):
|
||||||
|
> - 必须为 **字符串数组**,如 `["答案1", "答案2"]`
|
||||||
|
> - 不是列表(如传了字符串 `"答案1"`)→ 该题直接得 0 分,`grading_status` 为 `"failed"`
|
||||||
|
> - 列表中某项不是字符串(如传了数字 `123`)→ 该题直接得 0 分
|
||||||
|
> - 校验失败不影响其他题目的正常批阅
|
||||||
|
|
||||||
**请求示例:**
|
**请求示例:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -366,8 +452,11 @@ Content-Type: application/json
|
|||||||
|
|
||||||
| 错误码 | HTTP | 说明 |
|
| 错误码 | HTTP | 说明 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `INVALID_ANSWER_FORMAT` | 400 | 答案格式不正确 |
|
| `MISSING_PARAMS` | 400 | 缺少 answers 字段 |
|
||||||
| `GRADING_ERROR` | 500 | 批阅过程出错 |
|
| `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 秒超时
|
- 设置 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
|
**文档版本**: v1.3
|
||||||
**更新时间**: 2026-05-17
|
**更新时间**: 2026-07-19
|
||||||
**相关文档**: [后端对接规范.md](./后端对接规范.md)
|
**相关文档**: [后端对接规范.md](./后端对接规范.md)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import os
|
|||||||
|
|
||||||
# 导入考试管理模块
|
# 导入考试管理模块
|
||||||
from exam_pkg.manager import (
|
from exam_pkg.manager import (
|
||||||
|
check_file_indexed,
|
||||||
generate_questions_from_file,
|
generate_questions_from_file,
|
||||||
grade_answers,
|
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
|
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__)
|
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 ====================
|
# ==================== 出题 API ====================
|
||||||
|
|
||||||
@exam_bp.route('/generate', methods=['POST'])
|
@exam_bp.route('/generate', methods=['POST'])
|
||||||
@@ -152,13 +185,14 @@ 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())
|
total_requested = sum(question_types.values())
|
||||||
if total_requested > MAX_TOTAL_QUESTIONS:
|
if total_requested <= 0:
|
||||||
return error_response("INVALID_PARAMS", BAD_REQUEST,
|
return error_response("INVALID_PARAMS", BAD_REQUEST,
|
||||||
f"总题数不能超过 {MAX_TOTAL_QUESTIONS} 道,当前请求 {total_requested} 道",
|
"总题数必须大于0",
|
||||||
http_status=400)
|
http_status=400)
|
||||||
|
if total_requested > 50:
|
||||||
|
logger.warning(f"[出题] 请求生成 {total_requested} 道题,可能影响性能")
|
||||||
|
|
||||||
# 校验排除题干列表(可选)
|
# 校验排除题干列表(可选)
|
||||||
exclude_stems = data.get('exclude_stems')
|
exclude_stems = data.get('exclude_stems')
|
||||||
@@ -169,7 +203,7 @@ def api_generate_questions():
|
|||||||
# 获取当前用户
|
# 获取当前用户
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
if not 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(
|
if not check_collection_permission(
|
||||||
@@ -178,9 +212,9 @@ def api_generate_questions():
|
|||||||
collection_name=collection,
|
collection_name=collection,
|
||||||
operation="read"
|
operation="read"
|
||||||
):
|
):
|
||||||
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
|
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
|
||||||
|
|
||||||
# 调用新版出题接口
|
# 调用新版出题接口(同步)
|
||||||
result = generate_questions_from_file(
|
result = generate_questions_from_file(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
collection=collection,
|
collection=collection,
|
||||||
@@ -191,6 +225,15 @@ def api_generate_questions():
|
|||||||
exclude_stems=data.get('exclude_stems')
|
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="出题成功")
|
return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -251,7 +294,7 @@ def api_generate_smart():
|
|||||||
# 获取当前用户
|
# 获取当前用户
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
if not 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(
|
if not check_collection_permission(
|
||||||
@@ -260,7 +303,23 @@ def api_generate_smart():
|
|||||||
collection_name=collection,
|
collection_name=collection,
|
||||||
operation="read"
|
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 分析文件,获取推荐的题型和数量
|
# 1. 调用 AI 分析文件,获取推荐的题型和数量
|
||||||
from exam_pkg.manager import analyze_file_for_exam
|
from exam_pkg.manager import analyze_file_for_exam
|
||||||
@@ -285,6 +344,15 @@ def api_generate_smart():
|
|||||||
exclude_stems=data.get('exclude_stems')
|
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 分析信息
|
# 3. 在返回结果中添加 AI 分析信息
|
||||||
result['ai_analysis'] = ai_analysis
|
result['ai_analysis'] = ai_analysis
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ def validate_questions_schema(questions: List[Dict]) -> List[Dict]:
|
|||||||
if not content.get('data', {}).get('options'):
|
if not content.get('data', {}).get('options'):
|
||||||
continue
|
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)
|
validated.append(q)
|
||||||
|
|
||||||
return validated
|
return validated
|
||||||
@@ -1047,7 +1055,7 @@ def analyze_document_for_exam(chunks: List[Dict], max_total: int = None) -> Dict
|
|||||||
|
|
||||||
注意:
|
注意:
|
||||||
- 不适合的题型数量设为 0
|
- 不适合的题型数量设为 0
|
||||||
- 所有数量之和不要超过 {min(total_knowledge_points * 2, 20)}
|
- 所有数量之和不要超过 {min(total_knowledge_points * 2, max_total if max_total else 20)}
|
||||||
- 必须返回合法 JSON,不要有其他内容
|
- 必须返回合法 JSON,不要有其他内容
|
||||||
|
|
||||||
请直接输出 JSON:"""
|
请直接输出 JSON:"""
|
||||||
@@ -1300,12 +1308,21 @@ def generate_questions_structured_v2(
|
|||||||
|
|
||||||
if shortage_types:
|
if shortage_types:
|
||||||
logger.info(f" [v2] 补题: 缺少题型 {dict(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():
|
for q_type, shortage in shortage_types.items():
|
||||||
logger.info(f" 补充 {q_type} {shortage} 道...")
|
logger.info(f" 补充 {q_type} {shortage} 道...")
|
||||||
extra = generator._makeup_questions(chunks, q_type, shortage, difficulty, document_name)
|
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])
|
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)
|
type_counts = defaultdict(int)
|
||||||
|
|||||||
@@ -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:
|
def grade_objective(answer: Dict) -> Dict:
|
||||||
"""
|
"""
|
||||||
批阅客观题(选择/判断)
|
批阅客观题(选择/判断)
|
||||||
|
|
||||||
🔥 本地直接判断,无 LLM 调用
|
🔥 本地直接判断,无 LLM 调用
|
||||||
|
判断题返回的 student_answer / correct_answer 统一为 bool (true/false)
|
||||||
"""
|
"""
|
||||||
q_type = answer['question_type']
|
q_type = answer['question_type']
|
||||||
question_content = answer.get('content', {})
|
question_content = answer.get('content', {})
|
||||||
@@ -100,17 +122,39 @@ def grade_objective(answer: Dict) -> Dict:
|
|||||||
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)
|
||||||
|
|
||||||
# 判断正确性
|
# 判断题:归一化为 bool 比较 + bool 输出
|
||||||
if q_type == 'single_choice':
|
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
|
correct = student_answer == correct_answer
|
||||||
elif q_type == 'multiple_choice':
|
elif q_type == 'multiple_choice':
|
||||||
# 多选题:答案顺序无关
|
# 多选题:答案顺序无关
|
||||||
correct = set(student_answer) == set(correct_answer) if isinstance(student_answer, list) else False
|
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:
|
else:
|
||||||
correct = False
|
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 {
|
return {
|
||||||
"question_id": answer.get('question_id'),
|
"question_id": answer.get('question_id'),
|
||||||
"score": max_score if correct else 0,
|
"score": max_score if correct else 0,
|
||||||
@@ -120,11 +164,49 @@ def grade_objective(answer: Dict) -> Dict:
|
|||||||
"correct": correct,
|
"correct": correct,
|
||||||
"student_answer": student_answer,
|
"student_answer": student_answer,
|
||||||
"correct_answer": correct_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:
|
def grade_fill_blank(answer: Dict) -> Dict:
|
||||||
"""
|
"""
|
||||||
批阅填空题 - 支持同义词匹配
|
批阅填空题 - 支持同义词匹配
|
||||||
@@ -137,6 +219,33 @@ def grade_fill_blank(answer: Dict) -> Dict:
|
|||||||
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)
|
||||||
|
|
||||||
|
# 校验学生答案格式(必须是列表)
|
||||||
|
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:
|
if not correct_answers or not student_answers:
|
||||||
return {
|
return {
|
||||||
"question_id": answer.get('question_id'),
|
"question_id": answer.get('question_id'),
|
||||||
|
|||||||
@@ -61,6 +61,113 @@ QUESTION_BANK_DIR = "./题库"
|
|||||||
DRAFT_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(
|
def generate_questions_from_file(
|
||||||
@@ -102,6 +209,28 @@ def generate_questions_from_file(
|
|||||||
"""
|
"""
|
||||||
options = options or {}
|
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. 检索文件的所有切片
|
# 1. 检索文件的所有切片
|
||||||
chunks = retrieve_file_chunks(
|
chunks = retrieve_file_chunks(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
@@ -227,7 +356,7 @@ def retrieve_file_chunks_for_analysis(
|
|||||||
collections = [collection] if isinstance(collection, str) else collection
|
collections = [collection] if isinstance(collection, str) else collection
|
||||||
|
|
||||||
# 提取文件名(向量库存储的是文件名,不含路径前缀)
|
# 提取文件名(向量库存储的是文件名,不含路径前缀)
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path.replace("\\", "/"))
|
||||||
|
|
||||||
# 向量检索(尝试文件名和完整路径)
|
# 向量检索(尝试文件名和完整路径)
|
||||||
results = None
|
results = None
|
||||||
@@ -295,7 +424,7 @@ def retrieve_file_chunks(
|
|||||||
query = build_semantic_query(question_types)
|
query = build_semantic_query(question_types)
|
||||||
|
|
||||||
# 提取文件名(向量库存储的是文件名,不含路径前缀)
|
# 提取文件名(向量库存储的是文件名,不含路径前缀)
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path.replace("\\", "/"))
|
||||||
|
|
||||||
# 统一处理为列表
|
# 统一处理为列表
|
||||||
if isinstance(collection, str):
|
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