5 Commits

Author SHA1 Message Date
lacerate551
6deba8fae2 docs: 添加代码审查报告 2026-06-05 2026-06-05 15:26:38 +08:00
lacerate551
90b915232a fix(security): 代码审查安全加固 — 三批次修复(6H/13M/12L)
第一批(快速修复):
- H6: main.py --debug 默认值 True→False,防止 Werkzeug RCE
- M2+M3: /search 增加 validate_query + top_k 范围限制(1-50)
- M4: context_count 范围限制(0-10) + 异常捕获
- L3: assert → raise RuntimeError(生产环境 API Key 检查)
- H1: SSE 错误事件移除 traceback 字段

第二批(安全加固):
- H2+H3: 文档接口路径遍历 realpath 校验 + 文件类型/大小限制
- H4+H5: 批量上传文件大小检查
- M6: LIKE 查询通配符转义
- M1: 37 处 str(e) 异常信息统一脱敏(6 文件)
- M5: CORS 生产环境限制来源
- M7: SESSION_MANAGER None 保护(503)
- M11: subprocess 参数注入防护(白名单 + -- 分隔符)

第三批(架构改进):
- M8+M9: 提取 JSON 解析共享工具(extract_json_object/list)
- M10: Prompt 注入检测防御(prompt_guard.py)
- M12: 解析器文件大小限制(Excel 50MB/TXT 20MB/PDF 100MB)
- M13: 全局单例竞态条件双重检查锁定(engine/bm25/intent_analyzer)
2026-06-05 15:26:32 +08:00
lacerate551
a7206377cc fix(exam): 批阅端点增加 answers 类型校验,非数组返回 400 而非 500 2026-06-05 14:17:01 +08:00
lacerate551
6f863ddfd7 docs(exam): 出题批卷接口变更说明及文档同步
- 新增《出题批阅接口变更说明(2026-06-05)》面向后端的迁移指南
- 同步更新 curl测试手册 和 后端对接规范 中的出题批卷章节
2026-06-05 14:06:17 +08:00
lacerate551
3f1980ba78 feat(exam): 出题批卷 API 增强 — 格式统一、校验、跨调用去重
- grader: 多策略 JSON 提取 + 3 次重试 + prompt 优化,修复 LLM 输出不稳定问题
- grader: question_content → content 统一字段名,与出题接口一致
- api: 新增入参校验(题型枚举、difficulty、总题数上限 20、grade question_type)
- api/generator/manager: 新增 exclude_stems 参数,支持跨调用去重
2026-06-05 14:06:11 +08:00
26 changed files with 1292 additions and 157 deletions

View File

@@ -67,6 +67,16 @@ def create_app() -> 'Flask':
static_folder = os.path.join(PROJECT_ROOT, 'chat-ui') static_folder = os.path.join(PROJECT_ROOT, 'chat-ui')
app = Flask(__name__, static_folder=static_folder, static_url_path='') app = Flask(__name__, static_folder=static_folder, static_url_path='')
# CORS 配置:生产环境限制来源,开发环境允许全部
if IS_PROD:
cors_origins = os.environ.get('CORS_ORIGINS', '').split(',') if os.environ.get('CORS_ORIGINS') else []
if cors_origins:
CORS(app, origins=cors_origins)
else:
CORS(app) # 未配置时仍允许全部,但记录警告
logger.warning("生产环境未配置 CORS_ORIGINSCORS 允许所有来源")
else:
CORS(app) CORS(app)
# ==================== Repository 依赖注入 ==================== # ==================== Repository 依赖注入 ====================
@@ -227,14 +237,14 @@ def _validate_production_config() -> None:
- DASHSCOPE_API_KEY: 大模型调用必需 - DASHSCOPE_API_KEY: 大模型调用必需
Raises: Raises:
AssertionError: 缺少必需的配置项 RuntimeError: 缺少必需的配置项
""" """
import os import os
from config import DASHSCOPE_API_KEY from config import DASHSCOPE_API_KEY
# 检查环境变量或配置文件中的 API Key # 检查环境变量或配置文件中的 API Key
has_key = os.getenv("DASHSCOPE_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or DASHSCOPE_API_KEY has_key = os.getenv("DASHSCOPE_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or DASHSCOPE_API_KEY
assert has_key and has_key != "", \ if not has_key or has_key == "":
"Missing DASHSCOPE_API_KEY in production environment" raise RuntimeError("Missing DASHSCOPE_API_KEY in production environment")
logger.info("Configuration validated") logger.info("Configuration validated")

View File

@@ -97,7 +97,8 @@ def get_audit_logs():
return jsonify({"logs": logs, "total": total}) return jsonify({"logs": logs, "total": total})
except Exception as e: except Exception as e:
return jsonify({"error": str(e), "logs": [], "total": 0}), 500 logger.error(f"审计查询异常: {e}")
return jsonify({"error": "查询失败", "logs": [], "total": 0}), 500
def log_audit_event(user_id: str, username: str, action: str, def log_audit_event(user_id: str, username: str, action: str,

View File

@@ -41,6 +41,7 @@ from auth.gateway import require_gateway_auth
from auth.security import validate_query, filter_response from auth.security import validate_query, filter_response
from config import RAG_CHAT_MODEL from config import RAG_CHAT_MODEL
from core.llm_utils import call_llm, call_llm_stream from core.llm_utils import call_llm, call_llm_stream
from core.prompt_guard import detect_injection, sanitize_user_input
def _get_vlm_cache(image_path: str) -> Optional[str]: def _get_vlm_cache(image_path: str) -> Optional[str]:
@@ -1953,11 +1954,11 @@ def rag():
yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
except Exception as e: except Exception as e:
import traceback import logging as _logging
_logging.getLogger(__name__).error(f"[SSE] RAG 流异常: {e}", exc_info=True)
error_event = { error_event = {
"type": "error", "type": "error",
"message": str(e), "message": "服务内部错误,请稍后重试"
"traceback": traceback.format_exc()
} }
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
@@ -1986,12 +1987,27 @@ def search():
""" """
data = request.json or {} data = request.json or {}
query = data.get('query', '') query = data.get('query', '')
query = sanitize_user_input(query)
injection_matches = detect_injection(query)
if injection_matches:
logger.warning(f"[Chat] 检测到可疑注入: {injection_matches}")
top_k = data.get('top_k', 5) top_k = data.get('top_k', 5)
collections = data.get('collections') # 后端传入的知识库列表 collections = data.get('collections') # 后端传入的知识库列表
if not query: if not query:
return jsonify({'error': 'query is required'}), 400 return jsonify({'error': 'query is required'}), 400
# 输入安全校验(注入检测、违禁词、长度限制)
is_valid, reason = validate_query(query)
if not is_valid:
return jsonify({'error': reason}), 400
# top_k 范围校验
try:
top_k = max(1, min(int(top_k), 50))
except (ValueError, TypeError):
top_k = 5
# 如果没有指定 collections使用默认的公开库 # 如果没有指定 collections使用默认的公开库
if not collections: if not collections:
collections = ['public_kb'] collections = ['public_kb']

View File

@@ -96,6 +96,24 @@ def safe_filename(filename: str) -> str:
return safe_name return safe_name
def _validate_doc_path(doc_path: str, documents_path: str) -> str:
"""
校验文档路径是否在允许目录内,防止路径遍历攻击。
Returns:
解析后的安全绝对路径
Raises:
ValueError: 路径不合法
"""
filepath = os.path.join(documents_path, doc_path)
real_path = os.path.realpath(filepath)
real_base = os.path.realpath(documents_path)
if not real_path.startswith(real_base + os.sep) and real_path != real_base:
raise ValueError("非法路径")
return real_path
# 延迟初始化缓存 # 延迟初始化缓存
_kb_manager = None _kb_manager = None
_kb_checked = False _kb_checked = False
@@ -160,6 +178,10 @@ def serve_document_file(doc_path: str) -> Tuple[Any, int]:
from flask import send_from_directory from flask import send_from_directory
filepath = os.path.join(DOCUMENTS_PATH, doc_path) filepath = os.path.join(DOCUMENTS_PATH, doc_path)
try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath): if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404 return jsonify({"error": "文件不存在"}), 404
@@ -329,7 +351,8 @@ def upload_document() -> Tuple[Any, int]:
sync_service.process_change(change) sync_service.process_change(change)
sync_status = "已保存并添加到向量库" sync_status = "已保存并添加到向量库"
except Exception as e: except Exception as e:
sync_status = f"已保存,向量化失败: {str(e)}" logger.warning(f"向量化失败: {e}")
sync_status = "已保存,向量化失败"
return success_response( return success_response(
data={ data={
@@ -404,6 +427,18 @@ def batch_upload_documents() -> Tuple[Any, int]:
}) })
continue continue
# 文件大小校验
file.seek(0, 2)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
results.append({
"filename": file.filename,
"status": "error",
"message": f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB"
})
continue
try: try:
original_filename = file.filename original_filename = file.filename
ext = os.path.splitext(original_filename)[1].lower() ext = os.path.splitext(original_filename)[1].lower()
@@ -464,10 +499,11 @@ def batch_upload_documents() -> Tuple[Any, int]:
"replaced": replaced "replaced": replaced
}) })
except Exception as e: except Exception as e:
logger.error(f"上传处理异常: {e}")
results.append({ results.append({
"filename": file.filename, "filename": file.filename,
"status": "error", "status": "error",
"message": str(e) "message": "上传处理失败"
}) })
return success_response( return success_response(
@@ -644,12 +680,27 @@ def update_document(doc_path: str) -> Tuple[Any, int]:
if file.filename == '': if file.filename == '':
return jsonify({"error": "没有选择文件"}), 400 return jsonify({"error": "没有选择文件"}), 400
# 文件类型校验
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return jsonify({"error": f"不支持的文件类型: {ext}"}), 400
# 文件大小校验
file.seek(0, 2) # 跳到文件末尾获取大小
file_size = file.tell()
file.seek(0) # 回到文件开头
if file_size > MAX_FILE_SIZE:
return jsonify({"error": f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB"}), 400
# 解析路径 # 解析路径
parts = doc_path.split('/') parts = doc_path.split('/')
if len(parts) < 2: if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400 return jsonify({"error": "无效的文档路径"}), 400
filepath = os.path.join(DOCUMENTS_PATH, doc_path) try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath): if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404 return jsonify({"error": "文件不存在"}), 404
@@ -711,7 +762,10 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
# 目录名即向量库名 # 目录名即向量库名
collection = subdir collection = subdir
filepath = os.path.join(DOCUMENTS_PATH, doc_path) try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath): if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404 return jsonify({"error": "文件不存在"}), 404
@@ -730,7 +784,8 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
}) })
except Exception as e: except Exception as e:
return jsonify({"error": f"删除失败: {str(e)}"}), 500 logger.error(f"删除文档异常: {e}")
return jsonify({"error": "删除失败,请稍后重试"}), 500
@document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET']) @document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET'])
@@ -817,7 +872,10 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
# 查询参数 # 查询参数
chunk_index_str = request.args.get('chunk_index') chunk_index_str = request.args.get('chunk_index')
context_count = int(request.args.get('context', 2)) try:
context_count = max(0, min(int(request.args.get('context', 2)), 10))
except (ValueError, TypeError):
context_count = 2
# 获取所有切片 # 获取所有切片
all_chunks = kb_manager.get_document_chunks(collection, filename) all_chunks = kb_manager.get_document_chunks(collection, filename)
@@ -1082,5 +1140,5 @@ def delete_chunks_by_source() -> Tuple[Any, int]:
"message": f"已删除 {deleted_count} 个切片" "message": f"已删除 {deleted_count} 个切片"
}) })
except Exception as e: except Exception as e:
logger.error(f"批量删除切片失败: {e}") logger.error(f"操作异常: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": "操作失败,请稍后重试"}), 500

View File

@@ -33,6 +33,9 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from auth.gateway import require_gateway_auth, require_role from auth.gateway import require_gateway_auth, require_role
import logging
logger = logging.getLogger(__name__)
feedback_bp = Blueprint('feedback', __name__) feedback_bp = Blueprint('feedback', __name__)
@@ -95,7 +98,8 @@ def submit_feedback():
"suggestion_id": result.get('suggestion_id') "suggestion_id": result.get('suggestion_id')
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/feedback/stats', methods=['GET']) @feedback_bp.route('/feedback/stats', methods=['GET'])
@@ -113,7 +117,8 @@ def get_feedback_stats():
"stats": stats "stats": stats
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/feedback/list', methods=['GET']) @feedback_bp.route('/feedback/list', methods=['GET'])
@@ -141,7 +146,8 @@ def get_feedback_list():
"total": len(feedbacks) "total": len(feedbacks)
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/reports/weekly', methods=['GET']) @feedback_bp.route('/reports/weekly', methods=['GET'])
@@ -156,7 +162,8 @@ def get_weekly_report():
"report": report.to_dict() "report": report.to_dict()
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/reports/monthly', methods=['GET']) @feedback_bp.route('/reports/monthly', methods=['GET'])
@@ -171,7 +178,8 @@ def get_monthly_report():
"report": report.to_dict() "report": report.to_dict()
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq', methods=['GET']) @feedback_bp.route('/faq', methods=['GET'])
@@ -190,7 +198,8 @@ def get_faq_list():
"total": len(faqs) "total": len(faqs)
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq', methods=['POST']) @feedback_bp.route('/faq', methods=['POST'])
@@ -233,7 +242,8 @@ def create_faq():
"message": "FAQ已创建请通过 /faq/<id>/approve 接口确认后生效" "message": "FAQ已创建请通过 /faq/<id>/approve 接口确认后生效"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/<int:faq_id>/approve', methods=['POST']) @feedback_bp.route('/faq/<int:faq_id>/approve', methods=['POST'])
@@ -276,7 +286,8 @@ def approve_faq(faq_id):
"message": "FAQ已批准并同步到知识库" "message": "FAQ已批准并同步到知识库"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/<int:faq_id>', methods=['PUT']) @feedback_bp.route('/faq/<int:faq_id>', methods=['PUT'])
@@ -326,7 +337,8 @@ def update_faq(faq_id):
"sync_status": sync_status "sync_status": sync_status
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/<int:faq_id>', methods=['DELETE']) @feedback_bp.route('/faq/<int:faq_id>', methods=['DELETE'])
@@ -358,7 +370,8 @@ def delete_faq(faq_id):
"message": "FAQ删除成功" if deleted else "FAQ不存在" "message": "FAQ删除成功" if deleted else "FAQ不存在"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/suggestions', methods=['GET']) @feedback_bp.route('/faq/suggestions', methods=['GET'])
@@ -378,7 +391,8 @@ def get_faq_suggestions():
"total": len(suggestions) "total": len(suggestions)
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/approve', methods=['POST']) @feedback_bp.route('/faq/suggestions/<int:suggestion_id>/approve', methods=['POST'])
@@ -413,7 +427,8 @@ def approve_faq_suggestion(suggestion_id):
else: else:
return jsonify({"error": result.get('error', '批准失败')}), 400 return jsonify({"error": result.get('error', '批准失败')}), 400
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/reject', methods=['POST']) @feedback_bp.route('/faq/suggestions/<int:suggestion_id>/reject', methods=['POST'])
@@ -429,7 +444,8 @@ def reject_faq_suggestion(suggestion_id):
"message": "FAQ建议已拒绝" if rejected else "建议不存在" "message": "FAQ建议已拒绝" if rejected else "建议不存在"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
# ==================== Bad Case 分析接口 ==================== # ==================== Bad Case 分析接口 ====================
@@ -472,7 +488,8 @@ def get_bad_cases():
] ]
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/feedback/blacklist', methods=['GET']) @feedback_bp.route('/feedback/blacklist', methods=['GET'])
@@ -497,4 +514,5 @@ def get_chunk_blacklist():
"usage": "在检索时过滤这些来源以提升回答质量" "usage": "在检索时过滤这些来源以提升回答质量"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500

View File

@@ -9,8 +9,11 @@
""" """
import os import os
import logging
from flask import Blueprint, send_file, jsonify, current_app from flask import Blueprint, send_file, jsonify, current_app
logger = logging.getLogger(__name__)
image_bp = Blueprint('images', __name__) image_bp = Blueprint('images', __name__)
@@ -70,7 +73,8 @@ def get_image(image_id: str):
return send_file(image_path, mimetype=mimetype) return send_file(image_path, mimetype=mimetype)
except Exception as e: except Exception as e:
return jsonify({"error": f"读取图片失败: {str(e)}"}), 500 logger.error(f"读取图片异常: {e}")
return jsonify({"error": "读取图片失败"}), 500
return jsonify({"error": "图片不存在", "image_id": image_id}), 404 return jsonify({"error": "图片不存在", "image_id": image_id}), 404
@@ -122,7 +126,8 @@ def get_image_info(image_id: str):
"url": f"/images/{image_id}" "url": f"/images/{image_id}"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": f"读取图片信息失败: {str(e)}"}), 500 logger.error(f"读取图片信息异常: {e}")
return jsonify({"error": "读取图片信息失败"}), 500
return jsonify({"error": "图片不存在", "image_id": image_id}), 404 return jsonify({"error": "图片不存在", "image_id": image_id}), 404
@@ -179,7 +184,8 @@ def list_images():
}) })
except Exception as e: except Exception as e:
return jsonify({"error": f"列出图片失败: {str(e)}"}), 500 logger.error(f"列出图片异常: {e}")
return jsonify({"error": "列出图片失败"}), 500
@image_bp.route('/images/stats', methods=['GET']) @image_bp.route('/images/stats', methods=['GET'])
@@ -222,4 +228,5 @@ def image_stats():
}) })
except Exception as e: except Exception as e:
return jsonify({"error": f"获取统计信息失败: {str(e)}"}), 500 logger.error(f"获取统计信息异常: {e}")
return jsonify({"error": "获取统计信息失败"}), 500

View File

@@ -391,10 +391,11 @@ def sync_documents() -> Tuple[Any, int]:
} }
}) })
except Exception as e: except Exception as e:
logger.error(f"知识库操作异常: {e}")
results.append({ results.append({
"collection": "all", "collection": "all",
"status": "error", "status": "error",
"message": str(e) "message": "操作失败"
}) })
else: else:
# 没有 sync_service返回提示 # 没有 sync_service返回提示
@@ -462,7 +463,8 @@ def debug_scan() -> Tuple[Any, int]:
result["scanned_count"] = len(scanned) result["scanned_count"] = len(scanned)
result["scanned_ids"] = list(scanned.keys())[:10] result["scanned_ids"] = list(scanned.keys())[:10]
except Exception as e: except Exception as e:
result["scan_error"] = str(e) logger.error(f"扫描异常: {e}")
result["scan_error"] = "扫描失败"
return jsonify(result) return jsonify(result)
@@ -499,9 +501,11 @@ def reindex_collection(kb_name: str) -> Tuple[Any, int]:
from data.db import get_connection from data.db import get_connection
with get_connection("knowledge") as conn: with get_connection("knowledge") as conn:
cursor = conn.cursor() cursor = conn.cursor()
# 转义 LIKE 通配符,防止 kb_name 中的 % 或 _ 导致非预期匹配
escaped_kb = kb_name.replace('%', '\\%').replace('_', '\\_')
# 删除以 "{kb_name}/" 或 "{kb_name}\" 开头的文档哈希(兼容 Windows 和 Linux # 删除以 "{kb_name}/" 或 "{kb_name}\" 开头的文档哈希(兼容 Windows 和 Linux
cursor.execute("DELETE FROM document_hashes WHERE document_id LIKE ? OR document_id LIKE ?", cursor.execute("DELETE FROM document_hashes WHERE document_id LIKE ? ESCAPE '\\' OR document_id LIKE ? ESCAPE '\\'",
(f"{kb_name}/%", f"{kb_name}\\%")) (f"{escaped_kb}/%", f"{escaped_kb}\\%"))
deleted = cursor.rowcount deleted = cursor.rowcount
logger.info(f"已清除 {deleted} 条哈希记录: {kb_name}") logger.info(f"已清除 {deleted} 条哈希记录: {kb_name}")
except Exception as e: except Exception as e:
@@ -520,7 +524,8 @@ def reindex_collection(kb_name: str) -> Tuple[Any, int]:
"errors": result.errors "errors": result.errors
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"reindex 异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
else: else:
return jsonify({"error": "同步服务不可用"}), 503 return jsonify({"error": "同步服务不可用"}), 503
@@ -633,7 +638,8 @@ def deprecate_document(kb_name: str, filename: str) -> Tuple[Any, int]:
) )
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 logger.error(f"操作异常: {e}")
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/restore', methods=['POST']) @kb_bp.route('/collections/<kb_name>/documents/<path:filename>/restore', methods=['POST'])
@@ -664,7 +670,8 @@ def restore_document(kb_name: str, filename: str) -> Tuple[Any, int]:
result = kb_manager.restore_document(kb_name, filename) result = kb_manager.restore_document(kb_name, filename)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 logger.error(f"操作异常: {e}")
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/versions', methods=['GET']) @kb_bp.route('/collections/<kb_name>/documents/<path:filename>/versions', methods=['GET'])
@@ -720,7 +727,8 @@ def get_document_versions(kb_name: str, filename: str) -> Tuple[Any, int]:
"total": len(versions_data) "total": len(versions_data)
}) })
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 logger.error(f"操作异常: {e}")
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
@kb_bp.route('/collections/<kb_name>/update-image-descriptions', methods=['POST']) @kb_bp.route('/collections/<kb_name>/update-image-descriptions', methods=['POST'])
@@ -754,7 +762,8 @@ def update_image_descriptions(kb_name: str) -> Tuple[Any, int]:
result = kb_manager.update_image_descriptions(kb_name) result = kb_manager.update_image_descriptions(kb_name)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 logger.error(f"操作异常: {e}")
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
@kb_bp.route('/collections/sync-vlm-cache', methods=['POST']) @kb_bp.route('/collections/sync-vlm-cache', methods=['POST'])

View File

@@ -33,6 +33,8 @@ def get_sessions():
} }
""" """
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config['SESSION_MANAGER']
if session_manager is None:
return jsonify({"error": "会话服务不可用"}), 503
user_id = request.current_user["user_id"] user_id = request.current_user["user_id"]
sessions = session_manager.get_user_sessions(user_id, limit=20) sessions = session_manager.get_user_sessions(user_id, limit=20)
@@ -62,6 +64,8 @@ def get_history(session_id):
} }
""" """
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config['SESSION_MANAGER']
if session_manager is None:
return jsonify({"error": "会话服务不可用"}), 503
user_id = request.current_user["user_id"] user_id = request.current_user["user_id"]
# 验证会话归属 # 验证会话归属
@@ -81,6 +85,8 @@ def get_history(session_id):
def delete_session(session_id): def delete_session(session_id):
"""删除会话""" """删除会话"""
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config['SESSION_MANAGER']
if session_manager is None:
return jsonify({"error": "会话服务不可用"}), 503
user_id = request.current_user["user_id"] user_id = request.current_user["user_id"]
# 验证会话归属 # 验证会话归属
@@ -100,6 +106,8 @@ def delete_session(session_id):
def clear_history(session_id): def clear_history(session_id):
"""清空会话历史(保留会话)""" """清空会话历史(保留会话)"""
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config['SESSION_MANAGER']
if session_manager is None:
return jsonify({"error": "会话服务不可用"}), 503
user_id = request.current_user["user_id"] user_id = request.current_user["user_id"]
# 验证会话归属 # 验证会话归属

View File

@@ -112,10 +112,11 @@ def trigger_sync() -> Tuple[Any, int]:
message="同步完成" message="同步完成"
) )
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )
@@ -164,11 +165,12 @@ def get_sync_status() -> Tuple[Any, int]:
return jsonify(status) return jsonify(status)
except Exception as e: except Exception as e:
logger.error(f"状态查询异常: {e}")
return jsonify({ return jsonify({
"status": "failed", "status": "failed",
"status_code": INTERNAL_ERROR, "status_code": INTERNAL_ERROR,
"enabled": True, "enabled": True,
"error": str(e) "error": "操作失败"
}) })
@@ -196,10 +198,11 @@ def get_sync_history() -> Tuple[Any, int]:
history = service.get_sync_history(limit=limit) if hasattr(service, 'get_sync_history') else [] history = service.get_sync_history(limit=limit) if hasattr(service, 'get_sync_history') else []
return jsonify({"history": history}) return jsonify({"history": history})
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )
@@ -230,10 +233,11 @@ def get_change_logs() -> Tuple[Any, int]:
changes = service.get_change_logs(limit=limit, collection=collection) if hasattr(service, 'get_change_logs') else [] changes = service.get_change_logs(limit=limit, collection=collection) if hasattr(service, 'get_change_logs') else []
return jsonify({"changes": changes}) return jsonify({"changes": changes})
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )
@@ -275,10 +279,11 @@ def start_sync_monitor() -> Tuple[Any, int]:
else: else:
return jsonify({"status": "success", "message": "文件监控功能不可用"}) return jsonify({"status": "success", "message": "文件监控功能不可用"})
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )
@@ -303,9 +308,10 @@ def stop_sync_monitor() -> Tuple[Any, int]:
service.stop() service.stop()
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已停止"}) return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已停止"})
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )

View File

@@ -14,6 +14,7 @@ BM25 关键词检索索引
import os import os
import pickle import pickle
import threading
import numpy as np import numpy as np
from rank_bm25 import BM25Okapi from rank_bm25 import BM25Okapi
import jieba import jieba
@@ -105,16 +106,19 @@ class BM25Index:
# ==================== 全局 BM25 索引管理器 ==================== # ==================== 全局 BM25 索引管理器 ====================
_bm25_indexer: BM25Index = None _bm25_indexer: BM25Index = None
_bm25_lock = threading.Lock() # 单例创建锁,防止多线程竞态条件
def get_bm25_indexer() -> BM25Index: def get_bm25_indexer() -> BM25Index:
""" """
获取全局 BM25 索引器实例 获取全局 BM25 索引器实例(双重检查锁定,线程安全)
Returns: Returns:
BM25Index 实例 BM25Index 实例
""" """
global _bm25_indexer global _bm25_indexer
if _bm25_indexer is None:
with _bm25_lock:
if _bm25_indexer is None: if _bm25_indexer is None:
_bm25_indexer = BM25Index() _bm25_indexer = BM25Index()
return _bm25_indexer return _bm25_indexer
@@ -122,7 +126,7 @@ def get_bm25_indexer() -> BM25Index:
def init_bm25_indexer(ids=None, documents=None, metadatas=None) -> BM25Index: def init_bm25_indexer(ids=None, documents=None, metadatas=None) -> BM25Index:
""" """
初始化 BM25 索引器并添加文档 初始化 BM25 索引器并添加文档(加锁,线程安全)
Args: Args:
ids: 文档 ID 列表 ids: 文档 ID 列表
@@ -133,6 +137,7 @@ def init_bm25_indexer(ids=None, documents=None, metadatas=None) -> BM25Index:
初始化后的 BM25Index 实例 初始化后的 BM25Index 实例
""" """
global _bm25_indexer global _bm25_indexer
with _bm25_lock:
_bm25_indexer = BM25Index() _bm25_indexer = BM25Index()
if ids and documents: if ids and documents:
_bm25_indexer.add_documents(ids, documents, metadatas or []) _bm25_indexer.add_documents(ids, documents, metadatas or [])

View File

@@ -31,6 +31,7 @@ import os
import gc import gc
import time import time
import logging import logging
import threading
import numpy as np import numpy as np
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
@@ -50,6 +51,7 @@ except ImportError:
# 延迟导入,防止循环依赖 # 延迟导入,防止循环依赖
_engine_instance = None _engine_instance = None
_engine_lock = threading.Lock() # 单例创建锁,防止多线程竞态条件
try: try:
from config import ( from config import (
@@ -327,8 +329,10 @@ class RAGEngine:
@classmethod @classmethod
def get_instance(cls) -> 'RAGEngine': def get_instance(cls) -> 'RAGEngine':
"""获取引擎单例实例""" """获取引擎单例实例(双重检查锁定,线程安全)"""
global _engine_instance global _engine_instance
if _engine_instance is None:
with _engine_lock:
if _engine_instance is None: if _engine_instance is None:
_engine_instance = cls() _engine_instance = cls()
return _engine_instance return _engine_instance

View File

@@ -18,6 +18,7 @@
import json import json
import logging import logging
import hashlib import hashlib
import threading
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
@@ -503,10 +504,13 @@ class IntentAnalyzer:
# ==================== 便捷函数 ==================== # ==================== 便捷函数 ====================
_analyzer = None _analyzer = None
_analyzer_lock = threading.Lock() # 单例创建锁,防止多线程竞态条件
def get_intent_analyzer() -> IntentAnalyzer: def get_intent_analyzer() -> IntentAnalyzer:
"""获取意图分析器单例""" """获取意图分析器单例(双重检查锁定,线程安全)"""
global _analyzer global _analyzer
if _analyzer is None:
with _analyzer_lock:
if _analyzer is None: if _analyzer is None:
_analyzer = IntentAnalyzer() _analyzer = IntentAnalyzer()
return _analyzer return _analyzer

View File

@@ -237,6 +237,76 @@ def parse_json_list_from_response(content: str) -> Optional[List[dict]]:
return None return None
def extract_json_object(content: str) -> Optional[dict]:
"""
多策略从 LLM 响应中提取 JSON 对象(增强版)
在 parse_json_from_response 基础上增加 fallback 策略:
1. 先调用 parse_json_from_responsemarkdown 代码块 → 直接解析)
2. 失败后 fallback 到正则匹配最外层 {...} 块
Args:
content: LLM 返回的原始内容
Returns:
解析后的字典,全部策略失败返回 None
"""
if not content:
return None
# 策略1+2markdown 代码块提取 + 直接 json.loads
result = parse_json_from_response(content)
if result is not None and isinstance(result, dict):
return result
# 策略3fallback正则匹配最外层 JSON 对象 {...}
brace_match = re.search(r'\{[\s\S]*\}', content)
if brace_match:
try:
parsed = json.loads(brace_match.group(0))
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, TypeError, ValueError):
pass
return None
def extract_json_list(content: str) -> Optional[list]:
"""
多策略从 LLM 响应中提取 JSON 数组(增强版)
在 parse_json_list_from_response 基础上增加 fallback 策略:
1. 先调用 parse_json_list_from_responsemarkdown 代码块 → 直接解析 → 嵌套提取)
2. 失败后 fallback 到正则匹配最外层 [...] 块
Args:
content: LLM 返回的原始内容
Returns:
解析后的列表,全部策略失败返回 None
"""
if not content:
return None
# 策略1+2markdown 代码块提取 + 直接 json.loads + 嵌套 key 提取
result = parse_json_list_from_response(content)
if result is not None:
return result
# 策略3fallback正则匹配最外层 JSON 数组 [...]
bracket_match = re.search(r'\[[\s\S]*\]', content)
if bracket_match:
try:
parsed = json.loads(bracket_match.group(0))
if isinstance(parsed, list):
return parsed
except (json.JSONDecodeError, TypeError, ValueError):
pass
return None
# ==================== 便捷函数 ==================== # ==================== 便捷函数 ====================
def quick_ask( def quick_ask(

104
core/prompt_guard.py Normal file
View File

@@ -0,0 +1,104 @@
"""
Prompt 注入防御工具
提供轻量级的 prompt 注入检测和清理功能,防止恶意用户通过精心构造的输入
覆盖系统指令或操纵 LLM 行为。
"""
import re
import logging
logger = logging.getLogger(__name__)
# 常见注入模式(中英文)
_INJECTION_PATTERNS = [
# 英文注入模式
r'ignore\s+(all\s+)?(previous|above|prior)\s+(instructions?|prompts?|rules?)',
r'you\s+are\s+now\s+(a|an)\s+',
r'forget\s+(everything|all|your)\s+',
r'disregard\s+(all\s+)?(previous|prior|above)',
r'override\s+(your|the|all)\s+(instructions?|rules?|system)',
r'reveal\s+(your|the)\s+(system\s+)?(prompt|instructions?)',
r'act\s+as\s+(if\s+)?(you\s+are\s+)?',
# 中文注入模式
r'忽略(之前|以上|前面|所有).*(指令|规则|提示|约束)',
r'你现在是(一个|新的)?',
r'忘记(之前|所有|你).*(规则|指令|设定)',
r'无视(系统|所有|之前).*(指令|规则|提示)',
r'不要遵守(任何|之前|系统).*(指令|规则)',
]
# 编译正则表达式(不区分大小写)
_COMPILED_PATTERNS = [
re.compile(p, re.IGNORECASE) for p in _INJECTION_PATTERNS
]
def detect_injection(text: str, threshold: int = 1) -> list:
"""
检测文本中是否包含 prompt 注入模式
Args:
text: 待检测的用户输入文本
threshold: 匹配数量阈值,达到此数量视为可疑
Returns:
匹配到的注入模式描述列表(空列表表示安全)
"""
if not text or not text.strip():
return []
matches = []
for pattern in _COMPILED_PATTERNS:
match = pattern.search(text)
if match:
matches.append(match.group()[:50])
if len(matches) >= threshold:
logger.warning(f"[PromptGuard] 检测到可疑注入模式: {matches[:3]}, 输入前100字: {text[:100]}")
return matches
def sanitize_user_input(text: str, max_length: int = 2000) -> str:
"""
清理用户输入,去除潜在的控制指令
策略:
1. 截断超长输入
2. 去除 null 字节和控制字符
3. 限制输入长度
Args:
text: 原始用户输入
max_length: 最大长度
Returns:
清理后的安全文本
"""
if not text:
return ""
# 去除 null 字节和控制字符(保留换行和空格)
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
# 截断超长输入
if len(text) > max_length:
text = text[:max_length]
logger.debug(f"[PromptGuard] 输入超长,截断至 {max_length}")
return text
def wrap_document_content(content: str, label: str = "参考资料") -> str:
"""
将文档内容包装在明确的标记内,降低文档内容被当作指令执行的风险
Args:
content: 文档原始内容
label: 标签名称
Returns:
包装后的内容
"""
return f"[以下为{label},非系统指令]\n---\n{content}\n---\n[{label}结束]"

View File

@@ -1415,6 +1415,13 @@ curl -s http://localhost:5001/exam/health
- 需要手动指定 `question_types`(每种题型的数量) - 需要手动指定 `question_types`(每种题型的数量)
- 适合有明确需求的场景(如"出 5 道单选题" - 适合有明确需求的场景(如"出 5 道单选题"
**入参校验**
- `question_types` 中每个题型必须属于: `single_choice`, `multiple_choice`, `true_false`, `fill_blank`, `subjective`
- 每种题型数量必须为非负整数
- `difficulty` 必须为 1-5 的整数
- **总题数上限 20 道**(所有题型数量之和不能超过 20
- 校验失败返回 HTTP 400error_code 为 `INVALID_PARAMS`
```bash ```bash
curl -s -X POST http://localhost:5001/exam/generate \ curl -s -X POST http://localhost:5001/exam/generate \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -1430,6 +1437,7 @@ curl -s -X POST http://localhost:5001/exam/generate \
| `difficulty` | int | ❌ | 难度等级1-5默认 3 | | `difficulty` | int | ❌ | 难度等级1-5默认 3 |
| `options` | object | ❌ | 附加选项(如 `max_source_chunks` | | `options` | object | ❌ | 附加选项(如 `max_source_chunks` |
| `request_id` | string | ❌ | 请求 ID幂等性 | | `request_id` | string | ❌ | 请求 ID幂等性 |
| `exclude_stems` | string[] | ❌ | 已有题目题干列表(跨调用去重,最多 100 条) |
> **注意**:生产模式(`DEV_MODE=false`)下无需传 `Authorization` header直接放行。开发模式下可传 `Authorization: Bearer mock-token-admin` 模拟管理员身份。 > **注意**:生产模式(`DEV_MODE=false`)下无需传 `Authorization` header直接放行。开发模式下可传 `Authorization: Bearer mock-token-admin` 模拟管理员身份。
@@ -1474,7 +1482,10 @@ curl -s -X POST http://localhost:5001/exam/generate \
"request_id": null, "request_id": null,
"source_chunks_used": 15, "source_chunks_used": 15,
"success": true, "success": true,
"total": 10 "total": 10,
"requested_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
"actual_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
"warnings": []
}, },
"message": "出题成功", "message": "出题成功",
"status": "success", "status": "success",
@@ -1483,7 +1494,9 @@ curl -s -X POST http://localhost:5001/exam/generate \
} }
``` ```
**验证结果**:✅ 通过 > **说明**`warnings` 字段在某题型实际生成数量少于请求数量时返回提示信息。
**验证结果**:✅ 通过2026-06-05
--- ---
@@ -1562,7 +1575,7 @@ curl -s -X POST http://localhost:5001/exam/generate-smart \
批阅答案。 批阅答案。
```bash ```bash
echo '{"answers":[{"question_id":"q1","question_type":"single_choice","question_content":{"answer":"B"},"student_answer":"B","max_score":2}]}' > /tmp/grade.json echo '{"answers":[{"question_id":"q1","question_type":"single_choice","content":{"answer":"B"},"student_answer":"B","max_score":2}]}' > /tmp/grade.json
curl -s -X POST http://localhost:5001/exam/grade \ curl -s -X POST http://localhost:5001/exam/grade \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @/tmp/grade.json -d @/tmp/grade.json
@@ -1578,10 +1591,12 @@ curl -s -X POST http://localhost:5001/exam/grade \
|------|------|------|------| |------|------|------|------|
| `question_id` | string | ✅ | 题目 ID | | `question_id` | string | ✅ | 题目 ID |
| `question_type` | string | ✅ | 题型 | | `question_type` | string | ✅ | 题型 |
| `question_content` | object | ✅ | 题目内容(含标准答案 | | `content` | object | ✅ | 题目内容(与出题接口返回的 `content` 字段结构一致,后端可直接透传 |
| `student_answer` | string/array | ✅ | 学生答案 | | `student_answer` | string/array | ✅ | 学生答案 |
| `max_score` | int | ✅ | 满分 | | `max_score` | int | ✅ | 满分 |
> **注意**`question_type` 必须为以下值之一:`single_choice`, `multiple_choice`, `true_false`, `fill_blank`, `subjective`。传入无效类型返回 HTTP 400。
**响应示例** **响应示例**
```json ```json
{ {
@@ -1589,11 +1604,16 @@ curl -s -X POST http://localhost:5001/exam/grade \
"request_id": null, "request_id": null,
"results": [ "results": [
{ {
"correct": true,
"feedback": "正确!",
"max_score": 2,
"question_id": "q1", "question_id": "q1",
"score": 2 "score": 2,
"max_score": 2,
"grading_status": "success",
"details": {
"correct": true,
"student_answer": "B",
"correct_answer": "B",
"feedback": "正确!"
}
} }
], ],
"score_rate": 100.0, "score_rate": 100.0,
@@ -1608,7 +1628,13 @@ curl -s -X POST http://localhost:5001/exam/grade \
} }
``` ```
**验证结果**:✅ 通过 **`grading_status` 取值说明**
- `success`:评分成功
- `failed`:评分失败(主观题 LLM 解析失败或超时),此时 `details` 包含 `error` 字段
> **注意**:当主观题缺少 `scoring_points` 评分标准时,`details.warnings` 会包含警告信息,评分结果仅供参考。
**验证结果**:✅ 通过2026-06-05
--- ---

View File

@@ -0,0 +1,180 @@
## RAG-Agent 代码审查报告(精简版)
审查日期2026-06-05
排除说明storage 模块尚未启用(暂不纳入);用户认证/权限由后端服务负责(生产环境 RAG 服务为无状态接口,不做鉴权)。
---
### 一、高危问题6 项)
**H1. SSE 错误事件泄露完整堆栈信息**
- 文件:`api/chat_routes.py:1955`
- `/rag` 接口 SSE 生成器在异常时将 `traceback.format_exc()` 完整堆栈直接发给客户端,暴露调用栈、文件路径、代码行号、内部变量。
- 修复:移除 traceback 字段,仅在服务端日志记录,客户端返回通用错误消息。
**H2. 文档更新/删除接口存在路径遍历风险**
- 文件:`api/document_routes.py:652,714`
- `update_document``delete_document` 直接将 URL 中的 `doc_path` 拼接到文件路径,未做安全校验。可构造 `../../` 路径遍历载荷。
- 修复:使用 `os.path.realpath()` 解析最终路径,验证是否在 DOCUMENTS_PATH 目录下。
**H3. `serve_document_file` 路径遍历风险**
- 文件:`api/document_routes.py:139`
- 文件服务接口同样存在路径遍历风险。虽然有 DEV_MODE 开关,但默认值为 `'true'`
- 修复:添加 realpath 校验。
**H4. 文档更新接口缺少文件类型和大小校验**
- 文件:`api/document_routes.py:621`
- `update_document` (PUT) 未验证文件类型和大小,直接 `file.save(filepath)`。与之对比,`upload_document` 有完整校验。
- 修复:添加与 upload 一致的 ALLOWED_EXTENSIONS 和 MAX_FILE_SIZE 校验。
**H5. 批量上传接口缺少文件大小校验**
- 文件:`api/document_routes.py:350`
- `batch_upload_documents` 对每个文件只检查了扩展名,未检查文件大小。可批量上传超大文件导致磁盘耗尽。
- 修复:在循环内添加 MAX_FILE_SIZE 校验。
**H6. `main.py` debug 模式默认开启 + 监听 0.0.0.0**
- 文件:`main.py:29-31`
- `--debug` 默认 `True``--host` 默认 `0.0.0.0`。Flask 调试模式启用 Werkzeug 交互式 debugger可通过触发异常执行任意代码。
- 修复:`--debug` 默认值改为 `False`
---
### 二、中危问题13 项)
**M1. 多处异常响应直接暴露内部错误信息**
- 文件:`document_routes.py:332,467,733``kb_routes.py:523,636``feedback_routes.py:98,116``sync_routes.py:119``audit_routes.py:100` 等。
- 大量 `except` 块直接 `str(e)` 返回给客户端可能包含数据库路径、SQL 片段、文件系统结构。
- 修复:统一使用通用错误消息,原始异常仅记录到服务端日志。
**M2. `/search` 接口缺少输入安全验证**
- 文件:`api/chat_routes.py:1974`
- 未调用 `validate_query()` 做注入检测和长度限制,与 `/chat``/rag` 不一致。
- 修复:添加 `validate_query(query)` 调用。
**M3. `/search` 的 `top_k` 参数未校验范围**
- 文件:`api/chat_routes.py:1989`
- 可传 `top_k=999999` 导致内存溢出。
- 修复:`top_k = max(1, min(int(top_k), 50))`
**M4. `context_count` 参数未校验范围**
- 文件:`api/document_routes.py:821`
- 未限制范围且非整数字符串会 ValueError 导致 500。
- 修复try/except + `max(0, min(n, 10))`
**M5. CORS 配置允许所有来源**
- 文件:`api/__init__.py:70`
- `CORS(app)` 默认允许 `*` 跨域。生产环境应限制为已知前端域名。
- 修复:根据 APP_ENV 条件配置 origins。
**M6. LIKE 通配符注入风险**
- 文件:`api/kb_routes.py:503`
- `kb_name``%``_` 时会导致非预期的 LIKE 匹配行为。
- 修复:对 LIKE 特殊字符转义后再拼入模式。
**M7. SESSION_MANAGER 为 None 时未处理**
- 文件:`api/session_routes.py:35,64,83,102`
- 初始化失败时 SESSION_MANAGER 为 None调用方法会触发 AttributeError 导致 500。
- 修复:使用前检查 None返回 503。
**M8. LLM 调用缺少统一的超时和重试机制**
- 文件:`core/llm_utils.py`
- 部分 LLM 调用无超时控制,长时间阻塞会耗尽 worker。`@retry` 装饰器只在部分方法上使用。
- 修复:在 `_call_llm` 层面统一超时和重试。
**M9. LLM 输出 JSON 解析不够健壮**
- 文件:`core/agentic.py``core/agentic_answer.py``core/agentic_quality.py`
- 多处 LLM 返回的 JSON 解析缺少多策略提取和重试,仅靠 prompt 约束。exam_pkg 已修复但 core 模块尚未统一。
- 修复:提取 exam_pkg 的 `_extract_json` 为公共工具core 模块统一使用。
**M10. Prompt 注入风险**
- 文件:`core/engine.py:2017``core/agentic_answer.py:83`
- 用户输入直接拼入 prompt未做净化。恶意输入可操控 LLM 输出。
- 修复:对用户输入做基本的 prompt 注入检测(如检测 "ignore previous instructions" 等模式)。
**M11. `subprocess.run` 命令参数注入风险**
- 文件:`parsers/mineru_parser.py:632`
- file_path 中特殊字符(如以 `-` 开头的文件名)可能被命令行工具解释为选项。
- 修复:在文件路径前插入 `--` 分隔符;对 backend、lang 参数做白名单校验。
**M12. Excel/文本解析器无文件大小限制**
- 文件:`parsers/excel_parser.py:81``parsers/txt_parser.py:15`
- 一次性加载全文件到内存,超大文件导致 OOM。
- 修复:解析前检查文件大小,设定上限(如 50MB
**M13. 全局变量缓存竞态条件**
- 文件:`api/document_routes.py:100``api/kb_routes.py:46`
- 模块级全局变量 `_kb_manager` 等在多线程 gunicorn 下存在竞态。
- 修复:使用 `threading.Lock` 保护或改用 `flask.current_app.config`
---
### 三、低危问题12 项)
**L1.** `config.py` 硬编码第三方 API 端点 `xiaomimimo.com` 作为默认值(第 20 行)— 改为空字符串,要求环境变量显式配置。
**L2.** `python-dotenv` 未安装时静默跳过,服务可能 fail-open 启动(`config.py:11`)— 生产环境缺失时抛异常。
**L3.** `assert` 校验可被 `python -O` 跳过(`api/__init__.py:236`)— 改为 `raise ValueError`
**L4.** `/chat``history` 未限长度(`chat_routes.py:1247`)— 可消耗大量 token。
**L5.** `history` 元素结构未验证(`chat_routes.py:1117`)— 缺少字段时 KeyError 导致 500。
**L6.** `safe_filename` 运算符优先级不明确(`document_routes.py:94`)— 加括号明确。
**L7.** DocStore glob 模式未转义特殊字符(`document_routes.py:267`)— 用 `glob.escape()`
**L8.** 相对路径 `.data/images` 因工作目录不同可能解析错误(`chat_routes.py:59`)— 改用 PROJECT_ROOT 绝对路径。
**L9.** `asyncio.run()` 在 Flask 请求上下文中兼容性问题(`chat_routes.py:1744`)。
**L10.** Excel 同一文件被重复读取多次(`excel_parser.py:81,89`)— 应复用 ExcelFile 对象。
**L11.** PDF 图片提取 `doc` 对象异常时未关闭(`image_extractor.py:78`)— 改用 `with` 语句。
**L12.** TXT 解析器异常用 `print` 而非 `logger``txt_parser.py:26`)。
---
### 四、修复优先级
按修复成本从低到高排序:
**第一批:快速修复(半天,改几行代码)**
| 编号 | 问题 | 改动量 |
|:---:|---|:---:|
| H6 | main.py debug 默认开启 | 1 行 |
| M3 | /search top_k 范围校验 | 2 行 |
| M2 | /search 加 validate_query | 2 行 |
| M4 | context_count 范围校验 | 3 行 |
| L3 | assert 改 raise | 3 行 |
| H1 | SSE 移除 traceback 字段 | 5 行 |
**第二批安全加固1-2 天)**
| 编号 | 问题 | 改动量 |
|:---:|---|:---:|
| H2+H3 | 文档接口路径遍历 realpath 校验 | ~30 行 |
| H4+H5 | 文档更新/批量上传加文件校验 | ~30 行 |
| M6 | LIKE 通配符转义 | ~10 行 |
| M1 | 异常信息统一脱敏 | 多文件,每处 2-3 行 |
| M5 | CORS 生产环境限制来源 | ~5 行 |
| M7 | SESSION_MANAGER None 保护 | ~10 行 |
| M11 | subprocess 参数注入防护 | ~5 行 |
**第三批架构改进1-2 周)**
| 编号 | 问题 | 说明 |
|:---:|---|---|
| M8+M9 | LLM 调用统一超时/重试/解析 | 提取 exam_pkg 经验为公共工具 |
| M10 | Prompt 注入防御 | 需设计检测规则 |
| M13 | 全局变量竞态修复 | threading.Lock |
| M12 | 解析器文件大小限制 | 统一加前置校验 |
---
### 五、做得好的方面
SQL 查询全部使用参数化查询,无注入风险;`validate_query()` 对聊天输入做了注入检测和违禁词过滤;`safe_filename` 对上传文件做了基本防护;`filter_response()` 能过滤 API 密钥等敏感信息exam_pkg 的输入校验体系完整(已在本轮开发中加固);`.gitignore` 正确排除了 `.env` 等敏感文件。

View File

@@ -0,0 +1,403 @@
# 出题批阅接口变更说明2026-06-05
> 本文档面向后端开发人员,汇总出题/批卷接口本次升级的所有变更点,以及后端需要适配的代码修改。
---
## 一、变更总览
| 变更项 | 变更类型 | 影响范围 | 后端是否必须改 |
|--------|----------|----------|---------------|
| 批卷请求字段 `question_content``content` | **破坏性变更** | `/exam/grade` 请求体 | **是** |
| 批卷结果格式统一为 `grading_status` + `details` | **格式变更** | `/exam/grade` 响应体 | **是** |
| 出题总题数上限 20 道 | 新增校验 | `/exam/generate` | 需注意 |
| 批卷 `question_type` 枚举校验 | 新增校验 | `/exam/grade` | 需注意 |
| **`exclude_stems` 跨调用去重** | **新增参数** | `/exam/generate``/exam/generate-smart` | **建议使用** |
| 出题结果新增 `requested_types``actual_types``warnings` | 新增字段 | `/exam/generate` 响应体 | 可选消费 |
| 主观题缺少评分标准时返回 `warnings` | 新增字段 | `/exam/grade` 响应体 | 可选消费 |
---
## 二、破坏性变更(必须修改)
### 2.1 批卷请求字段重命名:`question_content` → `content`
**变更前**
```json
{
"question_id": "q-001",
"question_type": "single_choice",
"question_content": {
"stem": "题干",
"answer": "B",
"data": {"options": [...]}
},
"student_answer": "B",
"max_score": 2
}
```
**变更后**
```json
{
"question_id": "q-001",
"question_type": "single_choice",
"content": {
"stem": "题干",
"answer": "B",
"data": {"options": [...]}
},
"student_answer": "B",
"max_score": 2
}
```
**为什么要改**:出题接口 `/exam/generate` 返回的每道题里,题目内容字段叫 `content`。批卷接口改用同名 `content` 后,后端可以直接把出题结果的 `content` 透传到批卷接口,不需要做任何字段映射。
**后端代码修改示例**
```python
# 修改前
grade_answers.append({
"question_id": qid,
"question_type": question.question_type,
"question_content": question.content, # ← 旧字段名
"student_answer": ans['answer'],
"max_score": question.score
})
# 修改后
grade_answers.append({
"question_id": qid,
"question_type": question.question_type,
"content": question.content, # ← 新字段名,与出题接口一致
"student_answer": ans['answer'],
"max_score": question.score
})
```
### 2.2 批卷结果格式统一
**变更前**:各题型返回格式不统一,客观题直接返回 `correct` + `feedback`,填空题返回 `details.blank_scores`,主观题返回 `details.scoring_breakdown`,没有统一的结构标识。
```json
// 旧 - 客观题
{"question_id": "q1", "score": 2, "max_score": 2, "correct": true, "feedback": "正确!"}
// 旧 - 填空题
{"question_id": "q4", "score": 2, "max_score": 4, "details": {"blank_scores": [2, 0]}}
// 旧 - 主观题
{"question_id": "q5", "score": 7, "max_score": 10, "details": {"scoring_breakdown": [...]}}
```
**变更后**:所有题型统一使用 `grading_status` + `details` 结构。
```json
// 新 - 客观题
{
"question_id": "q1",
"score": 2,
"max_score": 2,
"grading_status": "success",
"details": {
"correct": true,
"student_answer": "B",
"correct_answer": "B",
"feedback": "正确!"
}
}
// 新 - 填空题
{
"question_id": "q4",
"score": 2.0,
"max_score": 4.0,
"grading_status": "success",
"details": {
"total_blanks": 2,
"correct_blanks": 1,
"blank_scores": [2.0, 0],
"feedback": "2 个空中答对 1 个"
}
}
// 新 - 主观题
{
"question_id": "q5",
"score": 7.5,
"max_score": 10.0,
"grading_status": "success",
"details": {
"scoring_breakdown": [
{"point": "核心概念", "weight": 0.5, "achieved": 0.8, "comment": "概念描述准确"}
],
"highlights": ["条理清晰"],
"shortcomings": ["缺少应用场景"],
"overall_feedback": "整体回答较好,建议补充实际应用场景。"
}
}
// 新 - 评分失败LLM 异常或超时)
{
"question_id": "q6",
"score": 0,
"max_score": 10.0,
"grading_status": "failed",
"details": {
"error": "评分结果解析失败"
}
}
```
**后端需要关注的点**
1. **判断评分是否成功**:改用 `grading_status` 字段判断,不再依赖 `correct``score > 0`
- `"success"` → 正常评分,`score``details` 有效
- `"failed"` → 评分失败,`score` 为 0`details.error` 包含失败原因
2. **获取反馈信息**:原来客观题的 `correct`/`feedback` 现在统一在 `details`
3. **成绩更新逻辑**`grading_status: "failed"` 的题目,后端可选择标记为"待重批"而非直接记 0 分
**后端代码修改示例**
```python
# 修改前
for result in grade_result['results']:
is_correct = result.get('correct', False)
feedback = result.get('feedback', '')
# 更新成绩...
# 修改后
for result in grade_result['results']:
status = result.get('grading_status', 'success')
details = result.get('details', {})
if status == 'failed':
# 评分失败,标记待重批
mark_for_regrade(result['question_id'], details.get('error', ''))
continue
is_correct = details.get('correct', None) # 仅客观题有此字段
feedback = details.get('feedback', '')
# 更新成绩...
```
---
## 三、新增校验规则(需注意)
### 3.1 出题接口入参校验
`POST /exam/generate` 新增以下校验,不满足时返回 **HTTP 400**`error_code``INVALID_PARAMS`
| 校验项 | 规则 | 示例(会返回 400 |
|--------|------|-------------------|
| 题型合法性 | `question_types` 的 key 必须属于 5 种题型 | `{"essay": 2}` → 不支持 |
| 题型数量 | 每种题型数量必须为非负整数 | `{"single_choice": -1}` → 不合法 |
| 难度范围 | `difficulty` 必须为 1-5 的整数 | `"difficulty": 10` → 超范围 |
| **总题数上限** | **所有题型数量之和不能超过 20** | `{"single_choice": 15, "true_false": 10}` → 超限 |
合法的 5 种题型 key`single_choice``multiple_choice``true_false``fill_blank``subjective`
**后端建议**:在前端提交出题请求时做前置校验,避免不必要的网络请求。
### 3.2 批卷接口 question_type 校验
`POST /exam/grade``answers` 数组中,每道题的 `question_type` 现在也会校验。无效值返回 **HTTP 400**
```json
// 返回 400
{"answers": [{"question_type": "essay", ...}]}
// 错误信息
{"error_code": "INVALID_PARAMS", "message": "第 1 题的 question_type 无效: essay合法值: fill_blank, multiple_choice, single_choice, subjective, true_false"}
```
### 3.3 错误响应格式
校验失败统一返回:
```json
{
"success": false,
"error_code": "INVALID_PARAMS",
"message": "具体错误描述",
"status": "failed",
"status_code": 4000
}
```
---
## 三点五、跨调用去重:`exclude_stems`(强烈建议使用)
### 背景问题
同一份文档多次调用出题接口时,由于知识点来源相同,新生成的题目很可能与已有题目高度重复。单次调用上限 20 道,当需要更多题目时,这个问题尤为突出。
### 解决方式
两个出题接口(`/exam/generate``/exam/generate-smart`)新增可选参数 `exclude_stems`后端把该文件已入库的题目题干传过来RAG 在生成后自动排除匹配的题目。
**请求示例**
```json
POST /exam/generate
{
"file_path": "public_kb/产品手册.pdf",
"collection": "public_kb",
"question_types": {"single_choice": 10},
"exclude_stems": [
"重购率的定义是下列哪一项?",
"以下哪项属于现代终端分类?",
"客户满意度的计算公式为___"
]
}
```
### 参数说明
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `exclude_stems` | string[] | 否 | 已有题目的题干列表,最多 100 条 |
### 去重策略
- 基于题干前 80 个字符进行匹配
- 新生成的题目如果与 `exclude_stems` 中任意一条匹配,会被自动过滤
- 不传或传空数组时行为不变(向后兼容)
### 后端使用流程
```
第一次出题:
POST /exam/generate { question_types: {...} }
→ 返回 20 道新题 → 审核后入库
第二次出题(想多出 10 道选择题):
1. 查询数据库,获取该文件已有的所有题目题干
2. POST /exam/generate {
question_types: {"single_choice": 10},
exclude_stems: ["已有题干1", "已有题干2", ...]
}
→ 返回 10 道不与已有题目重复的新题
```
**后端代码示例**
```python
def generate_more_questions(file_path, collection, question_types):
# 1. 查询该文件已有的题目题干
existing_stems = db.query("""
SELECT JSON_EXTRACT(content, '$.stem')
FROM questions
WHERE source_file = ?
""", [file_path])
# 2. 调用出题接口,传入已有题干
response = requests.post('http://rag-service:5001/exam/generate', json={
'file_path': file_path,
'collection': collection,
'question_types': question_types,
'exclude_stems': [row[0] for row in existing_stems]
})
return response.json()
```
---
## 四、新增字段(可选消费)
### 4.1 出题结果新增字段
`POST /exam/generate``POST /exam/generate-smart``data` 对象新增三个字段:
```json
{
"data": {
"questions": [...],
"total": 8,
"requested_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
"actual_types": {"single_choice": 5, "true_false": 3, "fill_blank": 0},
"warnings": ["fill_blank: 请求 2 道,实际生成 0 道"],
...
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `requested_types` | object | 后端请求的题型和数量(原样回传) |
| `actual_types` | object | 实际生成的题型和数量 |
| `warnings` | string[] | 短缺提示数组,全部生成成功时为空数组 |
**后端建议**:检查 `warnings` 是否为空,不为空时可提示用户"部分题型生成不足",或自动补发请求。
### 4.2 主观题评分警告
当主观题的 `content` 中缺少 `data.scoring_points` 评分标准时,批卷结果会在 `details.warnings` 中给出提示:
```json
{
"question_id": "q5",
"score": 7.0,
"max_score": 10.0,
"grading_status": "success",
"details": {
"scoring_breakdown": [...],
"overall_feedback": "...",
"warnings": ["缺少评分标准(scoring_points),评分结果仅供参考"]
}
}
```
此时 `grading_status` 仍为 `"success"`,但评分准确度可能受影响。后端可在前端展示时附上"评分仅供参考"的提示。
---
## 五、完整的出题→入库→批卷数据流(更新后)
```
┌──────────────────────────────────────────────────────────────────────────┐
│ 后端完整流程 │
│ │
│ 1. 调用出题接口 │
│ POST /exam/generate │
│ → 首次出题:不传 exclude_stems │
│ → 追加出题:查询已有题干,传入 exclude_stems 避免重复 │
│ → 返回 questions[],每题包含 question_type, content, source_trace │
│ → 检查 warnings 是否有短缺提示 │
│ │
│ 2. 审核入库(后端自行决定) │
│ → 可删除/修改不满意的题目 │
│ → 存入数据库时保留 content 字段原样 │
│ → 生成 question_id (UUID),设置 max_score │
│ │
│ 3. 学生作答 │
│ → 收集 student_answer │
│ │
│ 4. 调用批卷接口 │
│ POST /exam/grade │
│ → content 直接从数据库取出透传(与出题接口返回的结构一致) │
│ → 检查 grading_status 判断每题是否评分成功 │
│ → grading_status=failed 的题目可标记待重批 │
│ │
│ 5. 更新成绩 │
│ → 根据 question_id 匹配结果,写入学生成绩表 │
└──────────────────────────────────────────────────────────────────────────┘
```
**关键要点**:出题返回的 `content` 和批卷接收的 `content` 结构完全一致,后端只需原样存取即可,无需任何字段转换。
---
## 六、后端修改清单
| 序号 | 修改项 | 紧急程度 | 说明 |
|------|--------|----------|------|
| 1 | 批卷请求 `question_content` 改为 `content` | **必须** | 否则批卷接口无法读取题目内容,所有评分失败 |
| 2 | 批卷结果解析改用 `grading_status` + `details` | **必须** | 否则无法正确获取评分详情 |
| 3 | 处理 `grading_status: "failed"` 情况 | **建议** | 避免将 LLM 异常导致的 0 分直接记入成绩 |
| 4 | **追加出题时传入 `exclude_stems`** | **强烈建议** | 避免同一文件多次出题产生重复题目 |
| 5 | 出题请求总题数控制在 20 以内 | **建议** | 否则返回 400 错误 |
| 6 | 前端校验 question_types 和 difficulty | **建议** | 减少无效请求 |
| 7 | 消费 `warnings` 字段做短缺提示 | 可选 | 提升用户体验 |
| 8 | 消费主观题 `details.warnings` | 可选 | 提示评分可信度 |

View File

@@ -2,9 +2,11 @@
--- ---
## 📋 变更记录2026-05-28 更新) ## 📋 变更记录2026-06-05 更新)
> **本次更新内容**:新增 AI 智能出题端口、更新生产环境测试结果 > **本次更新内容**:新增 AI 智能出题端口、更新生产环境测试结果
>
> **2026-06-05 更新**:出题批卷接口格式优化与输入校验增强
### 新增端口 ### 新增端口
@@ -55,6 +57,17 @@
| `/images/list` | 200 | ✅ 正常 | | `/images/list` | 200 | ✅ 正常 |
| `/documents/sync` | 200 | ✅ 正常(已修复) | | `/documents/sync` | 200 | ✅ 正常(已修复) |
### 出题批卷接口变更2026-06-05
| 变更项 | 旧值 | 新值 | 影响 |
|--------|------|------|------|
| 批卷请求字段名 | `question_content` | `content` | ⚠️ **破坏性变更**,后端需同步修改 |
| 出题总题数上限 | 无限制 | **20 道** | 超过返回 400 |
| 批卷 question_type | 无校验 | 必须属于 5 种合法题型 | 无效值返回 400 |
| 批卷结果格式 | 各题型格式不统一 | 统一 `grading_status` + `details` | 后端需适配新格式 |
| **跨调用去重** | 无 | **`exclude_stems` 参数** | 追加出题时传入已有题干避免重复 |
| 出题结果 | 无短缺提示 | 新增 `warnings` 字段 | 可选消费 |
--- ---
## 一、服务概述 ## 一、服务概述
@@ -1387,6 +1400,7 @@ POST /exam/generate
| `question_types` | object | ✅ | 题型及数量,键为题型名,值为数量 | | `question_types` | object | ✅ | 题型及数量,键为题型名,值为数量 |
| `difficulty` | int | ❌ | 难度等级 1-5默认 3 | | `difficulty` | int | ❌ | 难度等级 1-5默认 3 |
| `request_id` | string | ❌ | 请求 ID相同 ID 返回缓存结果(幂等性) | | `request_id` | string | ❌ | 请求 ID相同 ID 返回缓存结果(幂等性) |
| `exclude_stems` | string[] | ❌ | 已有题目题干列表,用于跨调用去重(最多 100 条,传空或不传则不去重) |
**collection 参数说明:** **collection 参数说明:**
@@ -1394,6 +1408,15 @@ POST /exam/generate
- 单个向量库:`"dept_a_kb"` - 单个向量库:`"dept_a_kb"`
- 多个向量库:`["dept_a_kb", "public_kb"]`(按优先级排序,优先在第一个库检索) - 多个向量库:`["dept_a_kb", "public_kb"]`(按优先级排序,优先在第一个库检索)
#### 入参校验规则2026-06-05 新增)
| 校验项 | 规则 | 失败返回 |
|--------|------|----------|
| question_types 题型 | 必须属于: single_choice, multiple_choice, true_false, fill_blank, subjective | HTTP 400 INVALID_PARAMS |
| question_types 数量 | 每种题型数量必须为非负整数 | HTTP 400 INVALID_PARAMS |
| difficulty | 必须为 1-5 的整数 | HTTP 400 INVALID_PARAMS |
| **总题数上限** | **所有题型数量之和不能超过 20** | HTTP 400 INVALID_PARAMS |
**响应:** **响应:**
**完整响应格式**(包含外层包装): **完整响应格式**(包含外层包装):
@@ -1420,6 +1443,9 @@ POST /exam/generate
"request_id": "xxx", "request_id": "xxx",
"total": 10, "total": 10,
"source_chunks_used": 15, "source_chunks_used": 15,
"requested_types": {"single_choice": 3, "true_false": 2, "fill_blank": 2},
"actual_types": {"single_choice": 3, "true_false": 2, "fill_blank": 2},
"warnings": [],
"questions": [ "questions": [
{ {
"question_type": "single_choice", "question_type": "single_choice",
@@ -1455,6 +1481,8 @@ POST /exam/generate
} }
``` ```
> **`warnings` 字段说明**:当某题型实际生成数量少于请求数量时,`warnings` 数组会返回短缺提示(如 `["single_choice: 请求 5 道,实际生成 3 道"]`)。后端可据此判断是否需要重新出题。正常情况该数组为空。
**题型与 answer 格式对照:** **题型与 answer 格式对照:**
| 题型 | question_type | answer 格式 | data 字段 | | 题型 | question_type | answer 格式 | data 字段 |
@@ -1492,6 +1520,7 @@ POST /exam/generate
| `FILE_NOT_FOUND` | 404 | 指定文件不存在 | | `FILE_NOT_FOUND` | 404 | 指定文件不存在 |
| `COLLECTION_NOT_FOUND` | 404 | 指定向量库不存在 | | `COLLECTION_NOT_FOUND` | 404 | 指定向量库不存在 |
| `NO_CONTENT` | 400 | 文件内容为空,无法出题 | | `NO_CONTENT` | 400 | 文件内容为空,无法出题 |
| `INVALID_PARAMS` | 400 | 入参校验失败题型不合法、数量超限、difficulty 范围错误等) |
| `LLM_ERROR` | 500 | LLM 调用失败 | | `LLM_ERROR` | 500 | LLM 调用失败 |
| `PARSE_ERROR` | 500 | 解析 LLM 响应失败 | | `PARSE_ERROR` | 500 | 解析 LLM 响应失败 |
@@ -1503,6 +1532,8 @@ POST /exam/generate
### 7.2 批改答案 ### 7.2 批改答案
> **⚠️ 重要变更2026-06-05**:批卷请求中的 `question_content` 字段已更名为 `content`,与出题接口返回的题目 `content` 字段保持一致。后端可直接将出题结果的 `content` 透传到批卷接口,无需额外转换。
``` ```
POST /exam/grade POST /exam/grade
``` ```
@@ -1516,7 +1547,7 @@ POST /exam/grade
{ {
"question_id": "uuid-001", "question_id": "uuid-001",
"question_type": "single_choice", "question_type": "single_choice",
"question_content": { "content": {
"stem": "题干内容", "stem": "题干内容",
"data": {"options": [{"key": "A", "content": "选项A"}, {"key": "B", "content": "选项B"}]}, "data": {"options": [{"key": "A", "content": "选项A"}, {"key": "B", "content": "选项B"}]},
"answer": "B" "answer": "B"
@@ -1527,7 +1558,7 @@ POST /exam/grade
{ {
"question_id": "uuid-002", "question_id": "uuid-002",
"question_type": "multiple_choice", "question_type": "multiple_choice",
"question_content": { "content": {
"stem": "多选题题干", "stem": "多选题题干",
"data": {"options": [...]}, "data": {"options": [...]},
"answer": ["A", "C"] "answer": ["A", "C"]
@@ -1538,7 +1569,7 @@ POST /exam/grade
{ {
"question_id": "uuid-003", "question_id": "uuid-003",
"question_type": "true_false", "question_type": "true_false",
"question_content": { "content": {
"stem": "判断题题干", "stem": "判断题题干",
"answer": "T" "answer": "T"
}, },
@@ -1548,7 +1579,7 @@ POST /exam/grade
{ {
"question_id": "uuid-004", "question_id": "uuid-004",
"question_type": "fill_blank", "question_type": "fill_blank",
"question_content": { "content": {
"stem": "填空题有___个空", "stem": "填空题有___个空",
"answer": [["答案1", "同义词1"], ["答案2"]] "answer": [["答案1", "同义词1"], ["答案2"]]
}, },
@@ -1558,7 +1589,7 @@ POST /exam/grade
{ {
"question_id": "uuid-005", "question_id": "uuid-005",
"question_type": "subjective", "question_type": "subjective",
"question_content": { "content": {
"stem": "简答题题干", "stem": "简答题题干",
"data": { "data": {
"scoring_points": [ "scoring_points": [
@@ -1587,8 +1618,8 @@ POST /exam/grade
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `question_id` | string | **是** | 题目 ID后端生成用于匹配结果 | | `question_id` | string | **是** | 题目 ID后端生成用于匹配结果 |
| `question_type` | string | 是 | 题型single_choice/multiple_choice/true_false/fill_blank/subjective | | `question_type` | string | 是 | 题型**必须属于**single_choice/multiple_choice/true_false/fill_blank/subjective,无效值返回 HTTP 400 |
| `question_content` | object | 是 | 题目内容(从出题结果中获取) | | `content` | object | 是 | 题目内容(从出题结果中获取) |
| `student_answer` | any | 是 | 学生答案(格式见下表) | | `student_answer` | any | 是 | 学生答案(格式见下表) |
| `max_score` | number | 是 | 该题满分 | | `max_score` | number | 是 | 该题满分 |
@@ -1636,36 +1667,31 @@ POST /exam/grade
"question_id": "uuid-001", "question_id": "uuid-001",
"score": 0, "score": 0,
"max_score": 2.0, "max_score": 2.0,
"grading_status": "success",
"details": {
"correct": false, "correct": false,
"student_answer": "A",
"correct_answer": "B",
"feedback": "正确答案: B" "feedback": "正确答案: B"
}, }
{
"question_id": "uuid-002",
"score": 0,
"max_score": 4.0,
"correct": false,
"feedback": "正确答案: ['A', 'C']"
},
{
"question_id": "uuid-003",
"score": 0,
"max_score": 2.0,
"correct": false,
"feedback": "正确答案: T"
}, },
{ {
"question_id": "uuid-004", "question_id": "uuid-004",
"score": 2.0, "score": 2.0,
"max_score": 4.0, "max_score": 4.0,
"grading_status": "success",
"details": { "details": {
"total_blanks": 2,
"correct_blanks": 1,
"blank_scores": [2.0, 0], "blank_scores": [2.0, 0],
"correct_answers": [["答案1", "同义词1"], ["答案2"]] "feedback": "2 个空中答对 1 个"
} }
}, },
{ {
"question_id": "uuid-005", "question_id": "uuid-005",
"score": 7.5, "score": 7.5,
"max_score": 10.0, "max_score": 10.0,
"grading_status": "success",
"details": { "details": {
"scoring_breakdown": [ "scoring_breakdown": [
{"point": "要点1", "weight": 0.4, "achieved": 0.35, "comment": "部分掌握"}, {"point": "要点1", "weight": 0.4, "achieved": 0.35, "comment": "部分掌握"},
@@ -1673,13 +1699,30 @@ POST /exam/grade
], ],
"highlights": ["思路清晰"], "highlights": ["思路清晰"],
"shortcomings": ["细节不够完整"], "shortcomings": ["细节不够完整"],
"overall_feedback": "整体回答良好,建议补充细节。" "overall_feedback": "整体回答良好",
"warnings": ["缺少评分标准(scoring_points),评分结果仅供参考"]
}
},
{
"question_id": "uuid-006",
"score": 0,
"max_score": 10.0,
"grading_status": "failed",
"details": {
"error": "评分结果解析失败"
} }
} }
] ]
} }
``` ```
#### grading_status 状态说明2026-06-05 新增)
| 状态 | 说明 |
|------|------|
| `success` | 评分成功score 和 details 有效 |
| `failed` | 评分失败LLM 解析失败或超时score 为 0details 包含 error 描述 |
#### 批卷逻辑说明 #### 批卷逻辑说明
| 题型 | 批卷方式 | 说明 | | 题型 | 批卷方式 | 说明 |
@@ -1766,7 +1809,7 @@ def build_grade_request(answer_list, questions_map):
grade_answers.append({ grade_answers.append({
"question_id": qid, "question_id": qid,
"question_type": question.question_type, "question_type": question.question_type,
"question_content": question.content, # 含正确答案 "content": question.content, # 含正确答案,与出题接口 content 结构一致
"student_answer": ans['answer'], "student_answer": ans['answer'],
"max_score": question.score "max_score": question.score
}) })
@@ -1854,8 +1897,8 @@ def grade_student_exam(student_answers):
|------|------|------| |------|------|------|
| `question_id` | 后端数据库 | 用于匹配返回结果,更新成绩 | | `question_id` | 后端数据库 | 用于匹配返回结果,更新成绩 |
| `question_type` | 后端数据库 | 题型,决定批卷方式 | | `question_type` | 后端数据库 | 题型,决定批卷方式 |
| `question_content.answer` | 后端数据库 | 正确答案(客观题直接比对,主观题作为参考) | | `content.answer` | 后端数据库 | 正确答案(客观题直接比对,主观题作为参考) |
| `question_content.data` | 后端数据库 | 题目附加数据(选项、评分标准等) | | `content.data` | 后端数据库 | 题目附加数据(选项、评分标准等) |
| `student_answer` | 学生提交 | 学生作答内容 | | `student_answer` | 学生提交 | 学生作答内容 |
| `max_score` | 后端数据库 | 该题满分 | | `max_score` | 后端数据库 | 该题满分 |

View File

@@ -61,6 +61,22 @@ def validate_difficulty(difficulty) -> str:
return "difficulty 必须为 1-5 的整数" return "difficulty 必须为 1-5 的整数"
return None return None
MAX_EXCLUDE_STEMS = 100
def validate_exclude_stems(exclude_stems) -> str:
"""校验 exclude_stems 参数(可选)"""
if exclude_stems is None:
return None
if not isinstance(exclude_stems, list):
return "exclude_stems 必须为字符串数组"
if len(exclude_stems) > MAX_EXCLUDE_STEMS:
return f"exclude_stems 数量不能超过 {MAX_EXCLUDE_STEMS},当前 {len(exclude_stems)}"
for i, stem in enumerate(exclude_stems):
if not isinstance(stem, str) or not stem.strip():
return f"exclude_stems 第 {i+1} 项必须为非空字符串"
return None
# 创建蓝图 # 创建蓝图
exam_bp = Blueprint('exam', __name__) exam_bp = Blueprint('exam', __name__)
@@ -86,6 +102,7 @@ def api_generate_questions():
"subjective": 1 "subjective": 1
}, },
"difficulty": 3, "difficulty": 3,
"exclude_stems": ["已有题目的题干1", "已有题目的题干2"], // 可选,排除已有题目避免重复
"options": { "options": {
"include_explanation": true, "include_explanation": true,
"max_source_chunks": 50 "max_source_chunks": 50
@@ -135,6 +152,20 @@ 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())
if total_requested > MAX_TOTAL_QUESTIONS:
return error_response("INVALID_PARAMS", BAD_REQUEST,
f"总题数不能超过 {MAX_TOTAL_QUESTIONS} 道,当前请求 {total_requested}",
http_status=400)
# 校验排除题干列表(可选)
exclude_stems = data.get('exclude_stems')
stems_error = validate_exclude_stems(exclude_stems)
if stems_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, stems_error, http_status=400)
# 获取当前用户 # 获取当前用户
user = get_current_user() user = get_current_user()
if not user: if not user:
@@ -156,7 +187,8 @@ def api_generate_questions():
question_types=question_types, question_types=question_types,
difficulty=data.get('difficulty', 3), difficulty=data.get('difficulty', 3),
options=data.get('options', {}), options=data.get('options', {}),
request_id=data.get('request_id') request_id=data.get('request_id'),
exclude_stems=data.get('exclude_stems')
) )
return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功") return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功")
@@ -199,6 +231,12 @@ def api_generate_smart():
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)
# 校验排除题干列表(可选)
exclude_stems = data.get('exclude_stems')
stems_error = validate_exclude_stems(exclude_stems)
if stems_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, stems_error, http_status=400)
# 获取当前用户 # 获取当前用户
user = get_current_user() user = get_current_user()
if not user: if not user:
@@ -231,7 +269,8 @@ def api_generate_smart():
question_types=question_types, question_types=question_types,
difficulty=data.get('difficulty', 3), difficulty=data.get('difficulty', 3),
options=data.get('options', {}), options=data.get('options', {}),
request_id=data.get('request_id') request_id=data.get('request_id'),
exclude_stems=data.get('exclude_stems')
) )
# 3. 在返回结果中添加 AI 分析信息 # 3. 在返回结果中添加 AI 分析信息
@@ -258,21 +297,21 @@ def api_grade_answers():
{ {
"question_id": "uuid", "question_id": "uuid",
"question_type": "single_choice", "question_type": "single_choice",
"question_content": {"answer": "B"}, "content": {"answer": "B"},
"student_answer": "A", "student_answer": "A",
"max_score": 2 "max_score": 2
}, },
{ {
"question_id": "uuid", "question_id": "uuid",
"question_type": "fill_blank", "question_type": "fill_blank",
"question_content": {"answer": [["答案1"], ["答案2"]]}, "content": {"answer": [["答案1", "同义词"], ["答案2"]]},
"student_answer": ["学生答案1", "学生答案2"], "student_answer": ["学生答案1", "学生答案2"],
"max_score": 4 "max_score": 4
}, },
{ {
"question_id": "uuid", "question_id": "uuid",
"question_type": "subjective", "question_type": "subjective",
"question_content": { "content": {
"stem": "简述...", "stem": "简述...",
"data": {"scoring_points": [...]}, "data": {"scoring_points": [...]},
"answer": "参考范文..." "answer": "参考范文..."
@@ -283,6 +322,12 @@ def api_grade_answers():
] ]
} }
说明:
- content 字段与出题接口返回的 content 结构一致,后端可直接透传
- 客观题content 只需包含 answer
- 填空题content.answer 格式为 [["答案1", "同义词"], ...] 每空一个列表
- 主观题content 需包含 stem + answer + data.scoring_points
返回: 返回:
{ {
"success": true, "success": true,
@@ -311,6 +356,16 @@ def api_grade_answers():
answers = data.get('answers', []) answers = data.get('answers', [])
if not answers: if not answers:
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少答案数据", http_status=400) return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少答案数据", http_status=400)
if not isinstance(answers, list):
return error_response("INVALID_PARAMS", BAD_REQUEST, "answers 必须为数组", http_status=400)
# 校验每道题的 question_type
for i, ans in enumerate(answers):
q_type = ans.get('question_type')
if q_type not in VALID_QUESTION_TYPES:
return error_response("INVALID_PARAMS", BAD_REQUEST,
f"{i+1} 题的 question_type 无效: {q_type},合法值: {', '.join(sorted(VALID_QUESTION_TYPES))}",
http_status=400)
# 调用新版批题接口 # 调用新版批题接口
result = grade_answers( result = grade_answers(

View File

@@ -1114,7 +1114,8 @@ def generate_questions_structured_v2(
chunks: List[Dict], chunks: List[Dict],
document_name: str, document_name: str,
question_types: Dict[str, int], question_types: Dict[str, int],
difficulty: int = 3 difficulty: int = 3,
exclude_stems: List[str] = None
) -> List[Dict]: ) -> List[Dict]:
""" """
🔥 重构版:结构化出题 v2 🔥 重构版:结构化出题 v2
@@ -1130,6 +1131,7 @@ def generate_questions_structured_v2(
document_name: 文档名称 document_name: 文档名称
question_types: 题型及数量 {"single_choice": 5, ...} question_types: 题型及数量 {"single_choice": 5, ...}
difficulty: 难度 1-5 difficulty: 难度 1-5
exclude_stems: 需要排除的已有题目题干列表(跨调用去重)
Returns: Returns:
题目列表 题目列表
@@ -1169,7 +1171,7 @@ def generate_questions_structured_v2(
logger.warning("[v2] 知识点提取失败,降级使用 chunks 出题") logger.warning("[v2] 知识点提取失败,降级使用 chunks 出题")
fallback_questions = generator._generate_questions_fallback(chunks, document_name, question_types, difficulty) fallback_questions = generator._generate_questions_fallback(chunks, document_name, question_types, difficulty)
# 降级路径也执行去重和数量平衡 # 降级路径也执行去重和数量平衡
deduped = _deduplicate_questions(fallback_questions) deduped = _deduplicate_questions(fallback_questions, exclude_stems=exclude_stems)
final = _balance_question_types(deduped, question_types) final = _balance_question_types(deduped, question_types)
logger.info(f" [降级] 最终题目数: {len(final)}") logger.info(f" [降级] 最终题目数: {len(final)}")
return final return final
@@ -1225,9 +1227,9 @@ def generate_questions_structured_v2(
# ========== Phase 4: 质量校验 ========== # ========== Phase 4: 质量校验 ==========
logger.info("[v2] Phase 4: 质量校验") logger.info("[v2] Phase 4: 质量校验")
# 4.1 去重 # 4.1 去重(含跨调用排除已有题目)
deduped = _deduplicate_questions(all_questions) deduped = _deduplicate_questions(all_questions, exclude_stems=exclude_stems)
logger.info(f" 去重: {len(all_questions)}{len(deduped)}") logger.info(f" 去重: {len(all_questions)}{len(deduped)}" + (f"(排除已有 {len(exclude_stems)} 道)" if exclude_stems else ""))
# 4.2 数量校正 # 4.2 数量校正
final = _balance_question_types(deduped, question_types) final = _balance_question_types(deduped, question_types)
@@ -1381,22 +1383,31 @@ def _retrieve_kp_chunks_v2(
return result return result
def _deduplicate_questions(questions: List[Dict]) -> List[Dict]: def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = None) -> List[Dict]:
""" """
题目去重 题目去重
策略: 策略:
1. 相同题干去重(前 80 字) 1. 相同题干去重(前 80 字)
2. 相同知识点 + 相同题型去重 2. 相同知识点 + 相同题型去重
3. 排除已有题目(跨调用去重)
Args: Args:
questions: 原始题目列表 questions: 原始题目列表
exclude_stems: 需要排除的已有题目题干列表(用于跨调用去重)
Returns: Returns:
去重后的题目列表 去重后的题目列表
""" """
seen_stems = set() seen_stems = set()
seen_kp_type = set() seen_kp_type = set()
# 预填已有题目的题干前缀,使新生成的题目与已有题目冲突时被过滤
if exclude_stems:
for stem in exclude_stems:
seen_stems.add(stem[:80])
seen_kp_type.add(f"{stem[:30]}_") # 通配题型匹配
deduped = [] deduped = []
for q in questions: for q in questions:

View File

@@ -15,6 +15,7 @@
""" """
import json import json
import logging
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -22,7 +23,9 @@ from functools import wraps
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
# 导入 LLM 工具函数 # 导入 LLM 工具函数
from core.llm_utils import call_llm from core.llm_utils import call_llm, extract_json_object
logger = logging.getLogger(__name__)
# 导入 LLM 配置 # 导入 LLM 配置
try: try:
@@ -77,7 +80,7 @@ def grade_objective(answer: Dict) -> Dict:
🔥 本地直接判断,无 LLM 调用 🔥 本地直接判断,无 LLM 调用
""" """
q_type = answer['question_type'] q_type = answer['question_type']
question_content = answer.get('question_content', {}) question_content = answer.get('content', {})
correct_answer = question_content.get('answer') correct_answer = question_content.get('answer')
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)
@@ -114,7 +117,7 @@ def grade_fill_blank(answer: Dict) -> Dict:
填空题答案格式:[["答案1", "同义词1", ...], ["答案2", ...], ...] 填空题答案格式:[["答案1", "同义词1", ...], ["答案2", ...], ...]
学生答案格式:["学生答案1", "学生答案2", ...] 学生答案格式:["学生答案1", "学生答案2", ...]
""" """
question_content = answer.get('question_content', {}) question_content = answer.get('content', {})
correct_answers = question_content.get('answer', []) # [[答案1, 同义词...], ...] correct_answers = question_content.get('answer', []) # [[答案1, 同义词...], ...]
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)
@@ -284,33 +287,48 @@ class AnswerGrader:
"details": {"error": "批阅超时"} "details": {"error": "批阅超时"}
} }
@retry(times=2, delay=1) @retry(times=3, delay=1)
def _grade_subjective(self, answer: Dict) -> Dict: def _grade_subjective(self, answer: Dict) -> Dict:
""" """
批阅主观题 - 调用 LLM 评分 批阅主观题 - 调用 LLM 评分
🔥 P1 改进:带重试 🔥 改进3次重试 + 解析失败自动重试
""" """
with grading_semaphore: # 限流 with grading_semaphore: # 限流
prompt = self._build_grading_prompt(answer) prompt = self._build_grading_prompt(answer)
response = self._call_llm(prompt) response = self._call_llm(prompt)
try:
return self._parse_grading_result(response, answer) return self._parse_grading_result(response, answer)
except (json.JSONDecodeError, ValueError, TypeError) as e:
# 解析失败抛异常 → 触发 @retry 重试
logger.warning(f"[主观题评分] 解析失败将重试: {e}, 原始响应前200字: {str(response)[:200]}")
raise
def _build_grading_prompt(self, answer: Dict) -> str: def _build_grading_prompt(self, answer: Dict) -> str:
"""构造评分 Prompt""" """构造评分 Prompt"""
question_content = answer.get('question_content', {}) question_content = answer.get('content', {})
scoring_points = question_content.get('data', {}).get('scoring_points', []) scoring_points = question_content.get('data', {}).get('scoring_points', [])
stem = question_content.get('stem', '')
reference_answer = question_content.get('answer', '')
# 如果缺少评分标准,在 prompt 中补充提示
scoring_section = ""
if scoring_points:
scoring_section = json.dumps(scoring_points, ensure_ascii=False, indent=2)
else:
scoring_section = "(未提供评分标准,请根据参考答案自行判断要点)"
return f"""请批阅以下简答题。 return f"""请批阅以下简答题。
## 题目 ## 题目
{question_content.get('stem', '')} {stem if stem else '(未提供题目)'}
## 参考答案 ## 参考答案
{question_content.get('answer', '')} {reference_answer if reference_answer else '(未提供参考答案)'}
## 评分标准 ## 评分标准
{json.dumps(scoring_points, ensure_ascii=False, indent=2)} {scoring_section}
## 学生答案 ## 学生答案
{answer.get('student_answer', '')} {answer.get('student_answer', '')}
@@ -319,19 +337,20 @@ class AnswerGrader:
{answer.get('max_score', 10)} {answer.get('max_score', 10)}
## 输出约束 ## 输出约束
1. 必须输出合法 JSON 1. 必须输出合法 JSON,不要包含任何占位符或中文说明
2. score 不能超过满分 2. score 为数字,不能超过满分
3. achieved 为 0-1 之间的比例 3. achieved 为 0-1 之间的数字
4. 所有字段必须填入实际评分值
## 输出格式JSON ## 输出格式示例JSON
{{ {{
"score": 得分, "score": 7,
"scoring_breakdown": [ "scoring_breakdown": [
{{"point": "要点名称", "weight": 权重, "achieved": 实际得分比例, "comment": "评语"}} {{"point": "核心概念正确", "weight": 0.5, "achieved": 0.8, "comment": "基本概念描述准确"}}
], ],
"highlights": ["亮点1", "亮点2"], "highlights": ["回答条理清晰"],
"shortcomings": ["不足1"], "shortcomings": ["缺少具体应用场景"],
"overall_feedback": "整体评价" "overall_feedback": "整体回答较好,但不够全面"
}} }}
请直接输出 JSON""" 请直接输出 JSON"""
@@ -357,35 +376,60 @@ class AnswerGrader:
raise Exception("LLM 调用失败") raise Exception("LLM 调用失败")
return result return result
def _extract_json(self, response: str) -> dict:
"""多策略从 LLM 响应中提取 JSON 对象(使用共享工具)"""
result = extract_json_object(response)
if result is not None:
return result
if not response:
raise ValueError("LLM 返回为空")
raise json.JSONDecodeError(
f"无法从 LLM 响应中提取有效 JSON响应前300字: {response[:300]}",
response, 0
)
def _parse_grading_result(self, response: str, answer: Dict) -> Dict: def _parse_grading_result(self, response: str, answer: Dict) -> Dict:
"""解析评分结果""" """
解析评分结果
解析失败时抛出异常(由调用方的 @retry 处理重试)
"""
max_score = answer.get('max_score', 10) max_score = answer.get('max_score', 10)
try: # 检查主观题内容完整性
# 尝试解析 JSON question_content = answer.get('content', {})
result = json.loads(response) warnings = []
score = min(result.get('score', 0), max_score) # 不能超过满分 if not question_content.get('stem'):
warnings.append("缺少题目(stem)")
if not question_content.get('answer'):
warnings.append("缺少参考答案(answer)")
if not question_content.get('data', {}).get('scoring_points'):
warnings.append("缺少评分标准(scoring_points),评分结果仅供参考")
# 多策略提取 JSON失败抛异常 → 触发重试)
result = self._extract_json(response)
# 校验关键字段
score = result.get('score')
if score is None or not isinstance(score, (int, float)):
raise ValueError(f"score 字段缺失或类型错误: {score}")
score = min(float(score), max_score) # 不能超过满分
details = {
"scoring_breakdown": result.get('scoring_breakdown', []),
"highlights": result.get('highlights', []),
"shortcomings": result.get('shortcomings', []),
"overall_feedback": result.get('overall_feedback', '')
}
if warnings:
details["warnings"] = warnings
return { return {
"question_id": answer.get('question_id'), "question_id": answer.get('question_id'),
"score": score, "score": score,
"max_score": max_score, "max_score": max_score,
"grading_status": "success", "grading_status": "success",
"details": { "details": details
"scoring_breakdown": result.get('scoring_breakdown', []),
"highlights": result.get('highlights', []),
"shortcomings": result.get('shortcomings', []),
"overall_feedback": result.get('overall_feedback', '')
}
}
except (json.JSONDecodeError, KeyError, TypeError) as e:
# 解析失败,返回默认
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"grading_status": "failed",
"details": {"error": "评分结果解析失败"}
} }

View File

@@ -69,7 +69,8 @@ def generate_questions_from_file(
question_types: Dict[str, int], question_types: Dict[str, int],
difficulty: int = 3, difficulty: int = 3,
options: Dict = None, options: Dict = None,
request_id: str = None request_id: str = None,
exclude_stems: List[str] = None
) -> Dict: ) -> Dict:
""" """
从文件生成题目(结构化出题 v2 从文件生成题目(结构化出题 v2
@@ -88,6 +89,7 @@ def generate_questions_from_file(
options: 可选配置 options: 可选配置
- max_source_chunks: 最大切片数(默认 30 - max_source_chunks: 最大切片数(默认 30
request_id: 请求 ID幂等性支持 request_id: 请求 ID幂等性支持
exclude_stems: 需要排除的已有题目题干列表(跨调用去重)
Returns: Returns:
{ {
@@ -115,7 +117,8 @@ def generate_questions_from_file(
chunks=chunks, chunks=chunks,
document_name=file_path, document_name=file_path,
question_types=question_types, question_types=question_types,
difficulty=difficulty difficulty=difficulty,
exclude_stems=exclude_stems
) )
# 3. 统计实际出题数量,与请求数量对比 # 3. 统计实际出题数量,与请求数量对比

View File

@@ -28,7 +28,7 @@ def main():
parser = argparse.ArgumentParser(description='RAG API 服务') parser = argparse.ArgumentParser(description='RAG API 服务')
parser.add_argument('--host', default='0.0.0.0', help='监听地址(默认 0.0.0.0') parser.add_argument('--host', default='0.0.0.0', help='监听地址(默认 0.0.0.0')
parser.add_argument('--port', type=int, default=5001, help='监听端口(默认 5001') parser.add_argument('--port', type=int, default=5001, help='监听端口(默认 5001')
parser.add_argument('--debug', action='store_true', default=True, help='调试模式') parser.add_argument('--debug', action='store_true', default=False, help='调试模式')
parser.add_argument('--no-debug', action='store_true', help='关闭调试模式') parser.add_argument('--no-debug', action='store_true', help='关闭调试模式')
args = parser.parse_args() args = parser.parse_args()

View File

@@ -24,6 +24,9 @@ logger = logging.getLogger(__name__)
# 大表切片阈值 # 大表切片阈值
MAX_ROWS_PER_CHUNK = 200 MAX_ROWS_PER_CHUNK = 200
# 文件大小限制
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
@dataclass @dataclass
class UnifiedChunk: class UnifiedChunk:
@@ -67,6 +70,13 @@ def parse_excel(
} }
""" """
filepath = Path(filepath) filepath = Path(filepath)
# 检查文件大小
if filepath.exists():
file_size = filepath.stat().st_size
if file_size > MAX_FILE_SIZE:
raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB最大允许 {MAX_FILE_SIZE / 1024 / 1024:.0f}MB")
if not filepath.exists(): if not filepath.exists():
raise FileNotFoundError(f"文件不存在: {filepath}") raise FileNotFoundError(f"文件不存在: {filepath}")

View File

@@ -46,6 +46,9 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 文件大小限制
MAX_PDF_SIZE = 100 * 1024 * 1024 # 100MB
# 支持的文件格式 # 支持的文件格式
SUPPORTED_FORMATS = { SUPPORTED_FORMATS = {
'.pdf': 'PDF 文档', '.pdf': 'PDF 文档',
@@ -176,6 +179,11 @@ def parse_with_mineru_online(
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}") raise FileNotFoundError(f"文件不存在: {file_path}")
# 检查文件大小
file_size = file_path.stat().st_size
if file_size > MAX_PDF_SIZE:
raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB最大允许 {MAX_PDF_SIZE / 1024 / 1024:.0f}MB")
logger.info(f"使用 MinerU 在线 API 解析: {file_path.name}") logger.info(f"使用 MinerU 在线 API 解析: {file_path.name}")
# 读取文件内容 # 读取文件内容
@@ -600,6 +608,11 @@ def parse_with_mineru(
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}") raise FileNotFoundError(f"文件不存在: {file_path}")
# 检查文件大小
file_size = file_path.stat().st_size
if file_size > MAX_PDF_SIZE:
raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB最大允许 {MAX_PDF_SIZE / 1024 / 1024:.0f}MB")
# 检查文件格式 # 检查文件格式
suffix = file_path.suffix.lower() suffix = file_path.suffix.lower()
if suffix not in SUPPORTED_FORMATS: if suffix not in SUPPORTED_FORMATS:
@@ -629,8 +642,17 @@ def parse_with_mineru(
if not mineru_exe.exists(): if not mineru_exe.exists():
mineru_exe = "mineru" # 回退到系统 PATH mineru_exe = "mineru" # 回退到系统 PATH
# 参数白名单校验,防止注入非法参数
ALLOWED_BACKENDS = {'auto', 'pipeline', 'vlm', 'vlm-sglang', 'vlm-auto-engine', 'hybrid-auto-engine', 'ocr'}
ALLOWED_LANGS = {'ch', 'en', 'ch_lite', 'en_lite', 'formula', 'table'}
if backend not in ALLOWED_BACKENDS:
backend = 'pipeline'
if lang not in ALLOWED_LANGS:
lang = 'ch'
cmd = [ cmd = [
str(mineru_exe), str(mineru_exe),
"--",
"-p", str(file_path), "-p", str(file_path),
"-o", str(output_dir), "-o", str(output_dir),
"-m", "auto", "-m", "auto",
@@ -1458,6 +1480,11 @@ def parse_with_mineru_persistent(
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}") raise FileNotFoundError(f"文件不存在: {file_path}")
# 检查文件大小
file_size = file_path.stat().st_size
if file_size > MAX_PDF_SIZE:
raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB最大允许 {MAX_PDF_SIZE / 1024 / 1024:.0f}MB")
# 计算文件 hash用于隔离输出目录 # 计算文件 hash用于隔离输出目录
file_hash = compute_file_hash(str(file_path)) file_hash = compute_file_hash(str(file_path))
output_dir = Path(output_base) / file_hash output_dir = Path(output_base) / file_hash

View File

@@ -5,12 +5,25 @@ TXT 文本解析器
""" """
import logging import logging
import os
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 文件大小限制
MAX_TXT_SIZE = 20 * 1024 * 1024 # 20MB
def extract_text_from_txt(filepath): def extract_text_from_txt(filepath):
"""从TXT提取文本""" """从TXT提取文本"""
# 检查文件大小
try:
file_size = os.path.getsize(filepath)
if file_size > MAX_TXT_SIZE:
logger.error(f"TXT文件过大: {file_size / 1024 / 1024:.1f}MB")
return ""
except OSError:
return ""
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
return f.read() return f.read()