init: RAG 知识库服务初始提交

- 后端 API(Flask + Gunicorn)
- RAG 引擎(混合检索 + 云端 Reranker + 引用溯源)
- 文档解析(MinerU + 多格式支持)
- Docker 生产部署配置
- 排除前端项目、敏感配置、模型文件
This commit is contained in:
lacerate551
2026-06-04 17:35:27 +08:00
commit 100d1a06eb
158 changed files with 64534 additions and 0 deletions

260
api/__init__.py Normal file
View File

@@ -0,0 +1,260 @@
"""
API 路由层 — Flask 应用工厂
本模块实现 Flask 应用工厂模式,负责:
- 创建和配置 Flask 应用实例
- 初始化核心服务AgenticRAG、同步服务
- 注册所有 API Blueprint
- 配置前端静态文件路由
核心路由模块:
- chat_routes.py : 问答接口 (/chat, /rag, /rag/stream, /search)
- kb_routes.py : 向量库管理 (/collections, /documents/sync)
- document_routes.py: 文档管理 (/documents/upload, /documents/list)
- sync_routes.py : 同步服务 (/sync, /sync/status)
- image_routes.py : 图片服务 (/images/*)
- exam_pkg/api.py : 出题系统 (/exam/generate, /exam/grade)
架构说明:
- 会话管理、审计日志、反馈系统由后端服务负责
- 权限验证由后端网关完成Header 注入)
- RAG 服务无状态,不存储用户数据
Example:
>>> from api import create_app
>>> app = create_app()
>>> app.run(host='0.0.0.0', port=5001)
"""
import sys
import os
import logging
from typing import Optional
# 确保项目根目录在路径中
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
logger = logging.getLogger(__name__)
def create_app() -> 'Flask':
"""
Flask 应用工厂函数
创建并配置 Flask 应用实例,完成以下初始化步骤:
1. 创建 Flask 应用,配置 CORS
2. 初始化 Repository会话存储
3. 初始化核心服务AgenticRAG、同步服务
4. 注册所有 API Blueprint
5. 配置前端静态文件路由
6. 执行生产环境配置校验
Returns:
Flask: 配置完成的 Flask 应用实例
Example:
>>> app = create_app()
>>> app.run(host='0.0.0.0', port=5001)
"""
from flask import Flask, send_from_directory
from flask_cors import CORS
from config import ENABLE_SESSION, ENABLE_FEEDBACK, IS_PROD
# 静态文件目录(前端)
static_folder = os.path.join(PROJECT_ROOT, 'chat-ui')
app = Flask(__name__, static_folder=static_folder, static_url_path='')
CORS(app)
# ==================== Repository 依赖注入 ====================
# 会话存储开发环境用SQLite生产环境无状态
if ENABLE_SESSION:
from repositories.sqlite_session_repo import SQLiteSessionRepo
app.session_repo = SQLiteSessionRepo()
logger.info("会话存储: SQLite (开发环境)")
else:
from repositories.stateless_session_repo import StatelessSessionRepo
app.session_repo = StatelessSessionRepo()
logger.info("会话存储: 无状态 (生产环境)")
# ==================== 核心服务初始化 ====================
# Agentic RAG 引擎
try:
from core.agentic import AgenticRAG
from config import ENABLE_WEB_SEARCH
agentic_rag = AgenticRAG(
enable_web_search=ENABLE_WEB_SEARCH,
)
app.config['AGENTIC_RAG'] = agentic_rag
logger.info(f"Agentic RAG 引擎已初始化(网络搜索={'启用' if ENABLE_WEB_SEARCH else '关闭'}")
except Exception as e:
app.config['AGENTIC_RAG'] = None
logger.warning(f"Agentic RAG 初始化失败: {e}")
# 同步服务
try:
from knowledge.sync import KnowledgeSyncService
from config import DOCUMENTS_PATH
sync_service = KnowledgeSyncService(documents_path=DOCUMENTS_PATH)
app.config['SYNC_SERVICE'] = sync_service
logger.info("知识库同步服务已初始化")
except Exception as e:
app.config['SYNC_SERVICE'] = None
logger.warning(f"知识库同步服务未启用: {e}")
# 会话管理器(仅开发环境)
if ENABLE_SESSION:
try:
from services.session import SessionManager
session_manager = SessionManager()
app.config['SESSION_MANAGER'] = session_manager
logger.info("会话管理器已初始化")
except Exception as e:
app.config['SESSION_MANAGER'] = None
logger.warning(f"会话管理器初始化失败: {e}")
# ==================== 注册 Blueprint ====================
# 核心 API
from api.chat_routes import chat_bp
from api.kb_routes import kb_bp
from api.document_routes import document_bp
from api.sync_routes import sync_bp
app.register_blueprint(chat_bp)
app.register_blueprint(kb_bp)
app.register_blueprint(document_bp)
app.register_blueprint(sync_bp)
# 图片服务
from api.image_routes import image_bp
app.register_blueprint(image_bp)
# 健康检查
from api.auth_routes import auth_bp
app.register_blueprint(auth_bp)
# 会话管理(仅开发环境)
if ENABLE_SESSION:
try:
from api.session_routes import session_bp
app.register_blueprint(session_bp)
logger.info("会话管理 API 已启用")
except ImportError as e:
logger.info(f"会话管理 API 未加载: {e}")
# 审计日志(仅开发环境,依赖 session.db
if ENABLE_SESSION:
try:
from api.audit_routes import audit_bp
app.register_blueprint(audit_bp)
logger.info("审计日志 API 已启用")
except ImportError as e:
logger.info(f"审计日志 API 未加载: {e}")
# 反馈系统(开发和生产环境都启用)
if ENABLE_FEEDBACK:
try:
from api.feedback_routes import feedback_bp
app.register_blueprint(feedback_bp)
logger.info("反馈系统 API 已启用")
except ImportError as e:
logger.info(f"反馈系统 API 未加载: {e}")
# 出题系统(可选)
try:
from exam_pkg.api import exam_bp
app.register_blueprint(exam_bp, url_prefix='/exam')
logger.info("出题系统 API 已启用: /exam")
except ImportError as e:
logger.info(f"出题系统 API 未加载: {e}")
# ==================== 生产环境启动校验 ====================
if IS_PROD:
_validate_production_config()
# ==================== 前端静态文件路由 ====================
# 首页
@app.route('/')
def serve_index():
"""首页"""
return send_from_directory(static_folder, 'index.html')
# 静态文件(需要明确指定,避免与 API 路由冲突)
@app.route('/app.js')
def serve_app_js():
return send_from_directory(static_folder, 'app.js')
@app.route('/style.css')
def serve_style_css():
return send_from_directory(static_folder, 'style.css')
@app.route('/exam.html')
def serve_exam_html():
return send_from_directory(static_folder, 'exam.html')
@app.route('/exam.js')
def serve_exam_js():
return send_from_directory(static_folder, 'exam.js')
@app.route('/api-test.html')
def serve_api_test_html():
return send_from_directory(static_folder, 'api-test.html')
@app.route('/api-test.js')
def serve_api_test_js():
return send_from_directory(static_folder, 'api-test.js')
# ==================== 启动信息 ====================
_print_startup_info(app)
return app
def _validate_production_config() -> None:
"""
生产环境启动前配置校验
检查必要的配置项是否存在:
- DASHSCOPE_API_KEY: 大模型调用必需
Raises:
AssertionError: 缺少必需的配置项
"""
import os
from config import DASHSCOPE_API_KEY
# 检查环境变量或配置文件中的 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 != "", \
"Missing DASHSCOPE_API_KEY in production environment"
logger.info("Configuration validated")
def _print_startup_info(app: 'Flask') -> None:
"""
打印启动信息摘要
在应用启动时输出注册的路由数量和主要端点列表,
便于运维人员确认服务状态。
Args:
app: Flask 应用实例
"""
route_count = len([rule for rule in app.url_map.iter_rules() if rule.endpoint != 'static'])
logger.info(f"应用初始化完成,共注册 {route_count} 个路由")
logger.info(" 问答接口: /chat, /rag, /rag/stream, /search")
logger.info(" 向量库: /collections, /collections/<name>")
logger.info(" 文档管理: /documents/upload, /documents/list, /documents/*")
logger.info(" 切片管理: /chunks/*")
logger.info(" 同步服务: /sync, /sync/status")
logger.info(" 图片服务: /images/*")
logger.info(" 健康检查: /health")

133
api/audit_routes.py Normal file
View File

@@ -0,0 +1,133 @@
"""
审计日志 API
路由:
- GET /audit/logs - 查询审计日志(管理员)
"""
from flask import Blueprint, request, jsonify
import logging
logger = logging.getLogger(__name__)
from auth.gateway import require_gateway_auth
from data.db import get_connection
audit_bp = Blueprint('audit', __name__)
@audit_bp.route('/audit/logs', methods=['GET'])
@require_gateway_auth
def get_audit_logs():
"""
查询审计日志
参数:
- limit: 返回条数默认50
- days: 查询天数默认7
- action: 操作类型过滤(可选)
返回:
{
"logs": [
{
"id": 1,
"user_id": "admin001",
"username": "admin",
"action": "rag_query",
"query": "xxx",
"result_summary": "...",
"role": "admin",
"department": "管理部",
"ip_address": "127.0.0.1",
"duration_ms": 1234,
"timestamp": "2025-01-01 12:00:00"
}
],
"total": 100
}
"""
limit = request.args.get('limit', 50, type=int)
days = request.args.get('days', 7, type=int)
action_filter = request.args.get('action', '')
try:
with get_connection("session") as conn:
# 构建查询
where_clauses = ["created_at >= datetime('now', ?)"]
params = [f'-{days} days']
if action_filter:
where_clauses.append("action = ?")
params.append(action_filter)
where_sql = " AND ".join(where_clauses)
# 查询总数
count_sql = f"SELECT COUNT(*) FROM audit_logs WHERE {where_sql}"
total = conn.execute(count_sql, params).fetchone()[0]
# 查询日志
query_sql = f"""
SELECT id, user_id, username, action, query, result_summary,
role, department, ip_address, duration_ms, created_at
FROM audit_logs
WHERE {where_sql}
ORDER BY created_at DESC
LIMIT ?
"""
params.append(limit)
rows = conn.execute(query_sql, params).fetchall()
logs = []
for row in rows:
logs.append({
"id": row[0],
"user_id": row[1],
"username": row[2],
"action": row[3],
"query": row[4],
"result_summary": row[5],
"role": row[6],
"department": row[7],
"ip_address": row[8],
"duration_ms": row[9],
"timestamp": row[10]
})
return jsonify({"logs": logs, "total": total})
except Exception as e:
return jsonify({"error": str(e), "logs": [], "total": 0}), 500
def log_audit_event(user_id: str, username: str, action: str,
query: str = None, result_summary: str = None,
role: str = None, department: str = None,
ip_address: str = None, duration_ms: int = None):
"""
记录审计日志(供其他模块调用)
Args:
user_id: 用户ID
username: 用户名
action: 操作类型rag_query, chat, feedback, sync 等)
query: 查询内容
result_summary: 结果摘要
role: 用户角色
department: 部门
ip_address: IP地址
duration_ms: 耗时(毫秒)
"""
try:
with get_connection("session") as conn:
conn.execute("""
INSERT INTO audit_logs
(user_id, username, action, query, result_summary,
role, department, ip_address, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (user_id, username, action, query, result_summary,
role, department, ip_address, duration_ms))
except Exception as e:
# 审计日志写入失败不应影响主流程
logger.debug(f"审计日志写入失败: {e}")
pass

197
api/auth_routes.py Normal file
View File

@@ -0,0 +1,197 @@
"""
认证与系统状态 API
路由:
- POST /auth/login - 模拟登录(仅开发环境)
- GET /stats - 系统统计 (管理员)
- GET /health - 健康检查
- GET /auth/me - 当前用户信息
"""
from flask import Blueprint, request, jsonify
from auth.gateway import require_gateway_auth, require_role, get_user_permissions, MOCK_USERS
import os
from pathlib import Path
from dotenv import load_dotenv
# 加载 .env 文件(从项目根目录)
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(env_path)
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/auth/login', methods=['POST'])
def mock_login():
"""
模拟登录(仅开发环境)
请求体:
{
"username": "admin",
"password": "admin123"
}
返回:
{
"token": "mock-token-admin",
"user": {
"user_id": "admin001",
"username": "admin",
"role": "admin",
"department": "管理部"
}
}
测试账号:
- admin / admin123 (管理员,管理部)
- admin2 / admin456 (管理员,技术部)
- admin3 / admin789 (管理员,运营部)
- manager / manager123 (经理,财务部)
- user / test123 (普通用户,技术部)
"""
# 默认开启开发模式(生产环境需设置 DEV_MODE=false
if os.environ.get('DEV_MODE', 'true').lower() == 'false':
return jsonify({"error": "仅开发环境可用,请设置 DEV_MODE=true"}), 403
data = request.json or {}
username = data.get('username')
password = data.get('password')
user = MOCK_USERS.get(username)
if not user or user['password'] != password:
return jsonify({"error": "用户名或密码错误"}), 401
return jsonify({
"token": f"mock-token-{username}",
"user": {
"user_id": user['user_id'],
"username": username,
"role": user['role'],
"department": user['department']
}
})
@auth_bp.route('/stats', methods=['GET'])
@require_gateway_auth
@require_role('admin')
def get_stats():
"""获取系统统计信息(仅管理员)"""
from flask import current_app
session_manager = current_app.config['SESSION_MANAGER']
return jsonify(session_manager.get_stats())
@auth_bp.route('/health', methods=['GET'])
def health():
"""健康检查"""
return jsonify({
"status": "ok",
"knowledge_base": "多向量库模式 (按集合提供服务)",
"bm25_index": "动态按需加载",
"mode": "Agentic RAG"
})
@auth_bp.route('/auth/me', methods=['GET'])
@require_gateway_auth
def get_current_user():
"""
获取当前用户信息
开发模式下支持模拟用户,生产模式下用户信息由后端控制。
"""
user = request.current_user
return jsonify({
"user_id": user["user_id"],
"username": user["username"],
"role": user["role"],
"department": user["department"],
"permissions": get_user_permissions(user["role"])
})
@auth_bp.route('/auth/users', methods=['GET'])
@require_gateway_auth
def get_users():
"""
获取用户列表(仅开发环境)
返回:
{
"users": [
{
"user_id": "admin001",
"username": "admin",
"role": "admin",
"department": "管理部",
"is_active": true
}
]
}
"""
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false'
if not dev_mode:
return jsonify({"error": "仅开发环境可用"}), 403
users = []
for username, info in MOCK_USERS.items():
users.append({
"user_id": info["user_id"],
"username": username,
"role": info["role"],
"department": info["department"],
"is_active": True # 模拟用户默认都是活跃状态
})
return jsonify({"users": users})
@auth_bp.route('/auth/users/<user_id>', methods=['PUT'])
@require_gateway_auth
def update_user(user_id):
"""
更新用户信息(仅开发环境,模拟操作)
请求体:
{
"is_active": false
}
"""
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false'
if not dev_mode:
return jsonify({"error": "仅开发环境可用"}), 403
# 模拟用户不支持真正的状态切换,直接返回成功
return jsonify({"message": "操作成功(模拟)", "user_id": user_id})
@auth_bp.route('/auth/change-password', methods=['POST'])
@require_gateway_auth
def change_password():
"""
修改密码(仅开发环境,模拟操作)
请求体:
{
"old_password": "xxx",
"new_password": "xxx"
}
"""
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false'
if not dev_mode:
return jsonify({"error": "仅开发环境可用"}), 403
data = request.json or {}
old_password = data.get('old_password')
new_password = data.get('new_password')
if not old_password or not new_password:
return jsonify({"error": "请提供旧密码和新密码"}), 400
if len(new_password) < 6:
return jsonify({"error": "新密码至少6位"}), 400
# 模拟环境直接返回成功
return jsonify({"message": "密码修改成功(模拟)"})

1998
api/chat_routes.py Normal file

File diff suppressed because it is too large Load Diff

987
api/document_routes.py Normal file
View File

@@ -0,0 +1,987 @@
"""
文档管理 API
本模块提供文档上传、查询、删除等管理功能,包括:
- 单文件和批量上传
- 文档列表查询
- 文档切片管理
路由列表:
POST /documents/upload : 上传文件(单个)
POST /documents/batch-upload : 批量上传文件
GET /documents/list : 文档列表
GET /documents/<doc_id>/status : 文件处理状态
PUT /documents/<doc_id> : 更新文件
DELETE /documents/<doc_path> : 删除文档
GET /documents/<doc_id>/chunks : 查看文件切片
GET /documents/<doc_id>/preview : 文档预览(支持按切片序号跳转)
切片管理:
POST /chunks : 新增切片
PUT /chunks/<chunk_id> : 修改切片
DELETE /chunks/<chunk_id> : 删除切片
架构说明:
- 权限验证由后端网关完成RAG 服务不做权限判断
- 文档存储在 documents/<collection>/ 目录下
- 上传后自动触发向量化(如果同步服务可用)
Example:
# 上传文件
curl -X POST http://localhost:5001/documents/upload \\
-H "Authorization: Bearer mock-token-admin" \\
-F "file=@report.pdf" \\
-F "collection=public_kb"
"""
import os
import re
import uuid
from datetime import datetime
from typing import Optional, Tuple, Any, List, Dict
from flask import Blueprint, request, jsonify
import logging
logger = logging.getLogger(__name__)
from werkzeug.utils import secure_filename
from auth.gateway import require_gateway_auth
from core.status_codes import (
UPLOAD_SUCCESS, BATCH_UPLOAD_SUCCESS, BAD_REQUEST,
NO_FILE, NO_FILE_SELECTED, NO_COLLECTION,
FILE_TOO_LARGE, UNSUPPORTED_FORMAT, INTERNAL_ERROR
)
from api.response_utils import success_response, error_response
document_bp = Blueprint('document', __name__)
# 文件限制
ALLOWED_EXTENSIONS = {'.pdf', '.docx', '.doc', '.xlsx', '.txt'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
def safe_filename(filename: str) -> str:
"""
安全文件名处理 - 保留中文,仅移除危险字符
与 werkzeug.secure_filename() 不同,此函数保留 Unicode 字符(包括中文),
仅移除路径分隔符和其他可能导致路径遍历攻击的字符。
Args:
filename: 原始文件名
Returns:
安全的文件名;无效输入返回空字符串
Example:
>>> safe_filename("财务报告.pdf")
'财务报告.pdf'
>>> safe_filename("../../../etc/passwd")
'etc_passwd'
"""
if not filename:
return ""
# 移除路径分隔符和危险字符
dangerous_chars = ['/', '\\', '..', '\x00', '\n', '\r', '\t']
safe_name = filename
for char in dangerous_chars:
safe_name = safe_name.replace(char, '_')
# 移除首尾空格和点
safe_name = safe_name.strip(' .')
# 如果文件名为空或只有扩展名,返回空
if not safe_name or safe_name.startswith('.') and safe_name.count('.') == 1:
return ""
return safe_name
# 延迟初始化缓存
_kb_manager = None
_kb_checked = False
def _get_kb_manager() -> Optional[Any]:
"""
获取知识库管理器(延迟加载)
Returns:
知识库管理器实例,导入失败返回 None
"""
global _kb_manager, _kb_checked
if not _kb_checked:
try:
from knowledge.manager import get_kb_manager
_kb_manager = get_kb_manager()
except ImportError as e:
logger.warning(f"知识库管理器导入失败: {e}")
_kb_checked = True
return _kb_manager
def _get_sync_service() -> Optional[Any]:
"""
获取同步服务实例
Returns:
同步服务实例,不可用时返回 None
"""
try:
from flask import current_app
return current_app.config.get('SYNC_SERVICE')
except Exception as e:
logger.debug(f"获取同步服务失败: {e}")
return None
# ==================== 文档管理 ====================
@document_bp.route('/documents/<path:doc_path>/raw', methods=['GET'])
@require_gateway_auth
def serve_document_file(doc_path: str) -> Tuple[Any, int]:
"""
返回文档原始文件(仅开发模式)
用于前端预览文档内容,生产环境禁用此接口。
Args:
doc_path: 文档相对路径collection/filename
Returns:
文件内容或错误响应
Note:
仅在 DEV_MODE=true 时可用
"""
if os.environ.get('DEV_MODE', 'true').lower() == 'false':
return jsonify({"error": "仅开发环境可用"}), 403
from config import DOCUMENTS_PATH
from flask import send_from_directory
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
directory = os.path.dirname(filepath)
filename = os.path.basename(filepath)
return send_from_directory(directory, filename)
@document_bp.route('/documents/upload', methods=['POST'])
@require_gateway_auth
def upload_document() -> Tuple[Any, int]:
"""
上传单个文件到知识库
接收文件上传,保存到指定向量库目录,并触发向量化。
表单参数:
file: 文件(必需)
collection: 目标向量库名称(必需)
支持的文件类型:
- PDF (.pdf)
- Word (.docx, .doc)
- Excel (.xlsx)
- 文本 (.txt)
文件大小限制:
- 最大 10MB
Returns:
成功: {"success": true, "data": {"file": {...}, "sync_status": "..."}}
失败: {"error": "...", "error_code": "..."}
Example:
curl -X POST http://localhost:5001/documents/upload \\
-F "file=@report.pdf" \\
-F "collection=public_kb"
"""
from config import DOCUMENTS_PATH
# 1. 检查文件
if 'file' not in request.files:
return error_response("NO_FILE", NO_FILE, "没有上传文件", http_status=400)
file = request.files['file']
if file.filename == '':
return error_response("NO_FILE_SELECTED", NO_FILE_SELECTED, "没有选择文件", http_status=400)
# 2. 获取目标向量库
collection = request.form.get('collection') or request.form.get('kb_name')
if not collection:
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定目标向量库 (collection 参数)", http_status=400)
# 3. 文件类型验证
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return error_response(
"UNSUPPORTED_FORMAT",
UNSUPPORTED_FORMAT,
f"不支持的文件类型: {ext},支持: pdf, docx, doc, xlsx, txt",
http_status=400
)
# 4. 文件大小验证
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return error_response("FILE_TOO_LARGE", FILE_TOO_LARGE, "文件大小超过限制 (最大 10MB)", http_status=400)
# 5. 保存文件到对应目录(目录名 = 向量库名)
target_subdir = collection
target_dir = os.path.join(DOCUMENTS_PATH, target_subdir)
os.makedirs(target_dir, exist_ok=True)
# 安全文件名 + 处理重名
original_filename = file.filename
ext = os.path.splitext(original_filename)[1].lower()
filename = safe_filename(original_filename)
if not filename:
filename = f"upload_{datetime.now().strftime('%Y%m%d%H%M%S')}{ext}"
filepath = os.path.join(target_dir, filename)
if os.path.exists(filepath):
timestamp = datetime.now().strftime('_%Y%m%d_%H%M%S')
name, ext_part = os.path.splitext(filename)
filename = f"{name}{timestamp}{ext_part}"
filepath = os.path.join(target_dir, filename)
file.save(filepath)
# 6. 触发向量化
sync_status = "已保存,等待手动同步"
sync_service = _get_sync_service()
if sync_service:
try:
from knowledge.sync import DocumentChange, ChangeType
change = DocumentChange(
document_id=f"{target_subdir}/{filename}",
document_name=filename,
change_type=ChangeType.ADDED,
old_hash=None,
new_hash=sync_service.calculate_file_hash(filepath),
change_time=datetime.now()
)
sync_service.process_change(change)
sync_status = "已保存并添加到向量库"
except Exception as e:
sync_status = f"已保存,向量化失败: {str(e)}"
return success_response(
data={
"file": {
"filename": filename,
"collection": collection,
"path": f"{target_subdir}/{filename}",
"size": file_size
},
"sync_status": sync_status
},
status_code=UPLOAD_SUCCESS,
message=f"文件上传成功,{sync_status}"
)
@document_bp.route('/documents/batch-upload', methods=['POST'])
@require_gateway_auth
def batch_upload_documents() -> Tuple[Any, int]:
"""
批量上传文件到知识库
支持同时上传多个文件到指定向量库目录。
表单参数:
files: 文件列表(必需)
collection: 目标向量库名称(必需)
Returns:
{
"success": true,
"data": {
"total": N,
"success_count": M,
"results": [...]
}
}
"""
from config import DOCUMENTS_PATH
# 检查文件
if 'files' not in request.files:
return error_response("NO_FILE", NO_FILE, "没有上传文件", http_status=400)
files = request.files.getlist('files')
if not files:
return error_response("NO_FILE_SELECTED", NO_FILE_SELECTED, "没有选择文件", http_status=400)
# 获取目标向量库
collection = request.form.get('collection') or request.form.get('kb_name')
if not collection:
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定目标向量库 (collection 参数)", http_status=400)
# 确定存储目录(目录名 = 向量库名)
target_subdir = collection
target_dir = os.path.join(DOCUMENTS_PATH, target_subdir)
os.makedirs(target_dir, exist_ok=True)
# 批量处理
results = []
for file in files:
if file.filename == '':
continue
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
results.append({
"filename": file.filename,
"status": "error",
"message": f"不支持的文件类型: {ext}"
})
continue
try:
original_filename = file.filename
ext = os.path.splitext(original_filename)[1].lower()
filename = safe_filename(original_filename)
if not filename:
filename = f"upload_{datetime.now().strftime('%Y%m%d%H%M%S')}{ext}"
filepath = os.path.join(target_dir, filename)
# 处理重名
if os.path.exists(filepath):
timestamp = datetime.now().strftime('_%Y%m%d_%H%M%S')
name, ext_part = os.path.splitext(filename)
filename = f"{name}{timestamp}{ext_part}"
filepath = os.path.join(target_dir, filename)
file.save(filepath)
results.append({
"filename": filename,
"status": "success",
"path": f"{target_subdir}/{filename}"
})
except Exception as e:
results.append({
"filename": file.filename,
"status": "error",
"message": str(e)
})
return success_response(
data={
"total": len(results),
"success_count": len([r for r in results if r["status"] == "success"]),
"results": results
},
status_code=BATCH_UPLOAD_SUCCESS,
message=f"批量上传完成,成功 {len([r for r in results if r['status'] == 'success'])}/{len(results)} 个文件"
)
@document_bp.route('/documents/list', methods=['GET'])
@require_gateway_auth
def list_documents() -> Tuple[Any, int]:
"""
获取文档列表
扫描文档目录,返回所有可用的文档信息。
查询参数:
collection: 过滤指定向量库(可选)
Returns:
{
"documents": [
{
"filename": "...",
"collection": "...",
"path": "...",
"size": N,
"last_modified": "ISO 8601"
},
...
],
"total": N
}
"""
from config import DOCUMENTS_PATH
collection = request.args.get('collection') or request.args.get('kb_name')
# 确定要扫描的目录(目录名 = 向量库名)
if collection:
subdirs = [collection]
else:
# 列出所有文档目录
subdirs = []
if os.path.exists(DOCUMENTS_PATH):
for d in os.listdir(DOCUMENTS_PATH):
if os.path.isdir(os.path.join(DOCUMENTS_PATH, d)):
subdirs.append(d)
documents = []
supported_extensions = {'.pdf', '.docx', '.doc', '.xlsx', '.txt'}
for subdir in subdirs:
level_dir = os.path.join(DOCUMENTS_PATH, subdir)
if not os.path.exists(level_dir):
continue
# 目录名即向量库名
coll_name = subdir
for filename in os.listdir(level_dir):
ext = os.path.splitext(filename)[1].lower()
if ext not in supported_extensions:
continue
filepath = os.path.join(level_dir, filename)
try:
stat = os.stat(filepath)
documents.append({
"filename": filename,
"collection": coll_name,
"path": f"{subdir}/{filename}",
"size": stat.st_size,
"last_modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
except Exception as e:
logger.warning(f"读取文件信息失败: {filename}, {e}")
# 按修改时间倒序
documents.sort(key=lambda x: x['last_modified'], reverse=True)
return jsonify({
"documents": documents,
"total": len(documents)
})
@document_bp.route('/documents/<path:doc_path>/status', methods=['GET'])
@require_gateway_auth
def get_document_status(doc_path: str) -> Tuple[Any, int]:
"""
获取文件处理状态
查询指定文档的向量化状态和切片数量。
Args:
doc_path: 文档相对路径collection/filename
Returns:
{
"success": true,
"status": "processed|pending|error",
"chunk_count": N,
"last_processed": "ISO 8601"
}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
subdir = parts[0]
filename = '/'.join(parts[1:])
# 目录名即向量库名
collection = subdir
# 获取文档信息
from config import DOCUMENTS_PATH
file_on_disk = os.path.isfile(os.path.join(DOCUMENTS_PATH, subdir, filename))
doc_info = kb_manager.get_document_info(collection, filename)
if not doc_info:
if file_on_disk:
return jsonify({
"success": True,
"status": "unprocessed",
"chunk_count": 0,
"last_processed": None
})
return jsonify({"error": "文档不存在"}), 404
return jsonify({
"success": True,
"status": doc_info.get("status", "unknown"),
"chunk_count": doc_info.get("total_chunks", 0),
"last_processed": doc_info.get("effective_date") or doc_info.get("version")
})
@document_bp.route('/documents/<path:doc_path>', methods=['PUT'])
@require_gateway_auth
def update_document(doc_path: str) -> Tuple[Any, int]:
"""
更新文件(重新上传覆盖)
替换现有文件内容,并触发重新向量化。
Args:
doc_path: 文档相对路径collection/filename
表单参数:
file: 新文件(必需)
Returns:
{"success": true, "message": "文件已更新"}
"""
from config import DOCUMENTS_PATH
if 'file' not in request.files:
return jsonify({"error": "没有上传文件"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"error": "没有选择文件"}), 400
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
# 覆盖文件
file.save(filepath)
# 触发重新向量化
sync_service = _get_sync_service()
if sync_service:
try:
subdir = parts[0]
filename = '/'.join(parts[1:])
from knowledge.sync import DocumentChange, ChangeType
change = DocumentChange(
document_id=doc_path,
document_name=filename,
change_type=ChangeType.MODIFIED,
old_hash=None,
new_hash=sync_service.calculate_file_hash(filepath),
change_time=datetime.now()
)
sync_service.process_change(change)
except Exception as e:
logger.warning(f"重新向量化失败: {e}")
return jsonify({
"success": True,
"message": "文件已更新"
})
@document_bp.route('/documents/<path:doc_path>', methods=['DELETE'])
@require_gateway_auth
def delete_document(doc_path: str) -> Tuple[Any, int]:
"""
删除文档
同时删除向量库中的切片和物理文件。
Args:
doc_path: 文档相对路径collection/filename
Returns:
{"success": true, "message": "文档已删除"}
Note:
此操作不可逆,请谨慎使用
"""
from config import DOCUMENTS_PATH
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
subdir = parts[0]
filename = '/'.join(parts[1:])
# 目录名即向量库名
collection = subdir
filepath = os.path.join(DOCUMENTS_PATH, doc_path)
if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404
try:
# 1. 从向量库删除source 存的是文件名,不是完整路径)
kb_manager = _get_kb_manager()
if kb_manager:
kb_manager.delete_document(collection, filename)
# 2. 删除文件
os.remove(filepath)
return jsonify({
"success": True,
"message": "文档已删除"
})
except Exception as e:
return jsonify({"error": f"删除失败: {str(e)}"}), 500
@document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET'])
@require_gateway_auth
def list_document_chunks(doc_path: str) -> Tuple[Any, int]:
"""
查看文件切片
返回指定文档的所有切片内容,用于调试和验证。
Args:
doc_path: 文档相对路径collection/filename
Returns:
{
"success": true,
"document_id": "...",
"collection": "...",
"chunks": [...],
"total": N
}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400
subdir = parts[0]
# 目录名即向量库名
collection = subdir
chunks = kb_manager.get_document_chunks(collection, os.path.basename(doc_path))
return jsonify({
"success": True,
"document_id": doc_path,
"collection": collection,
"chunks": chunks,
"total": len(chunks)
})
@document_bp.route('/documents/<path:doc_path>/preview', methods=['GET'])
@require_gateway_auth
def preview_document(doc_path: str) -> Tuple[Any, int]:
"""
文档预览接口(支持按切片序号跳转)
用于前端引用溯源点击跳转:给定 chunk_index返回目标切片及其上下文。
复用现有切片查询逻辑,不新增存储。
Query Params:
chunk_index (int): 目标切片序号(来自 citation 的 chunk_index 字段)
context (int): 上下文切片数,默认 2前后各取 2 个)
Returns:
{
"success": true,
"collection": "...",
"source": "文件名",
"total_chunks": N,
"target_index": M,
"chunks": [
{"id": "...", "content": "...", "metadata": {...}, "is_target": true/false},
...
]
}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 解析路径
parts = doc_path.split('/')
if len(parts) < 2:
return jsonify({"error": "无效的文档路径,格式: collection/filename"}), 400
collection = parts[0]
filename = os.path.basename(doc_path)
# 查询参数
chunk_index_str = request.args.get('chunk_index')
context_count = int(request.args.get('context', 2))
# 获取所有切片
all_chunks = kb_manager.get_document_chunks(collection, filename)
if not all_chunks:
return jsonify({"error": f"文档 '{filename}' 不存在或无切片"}), 404
total = len(all_chunks)
# 如果未指定 chunk_index返回前 5 个切片作为概览
if chunk_index_str is None:
preview_chunks = all_chunks[:5]
for c in preview_chunks:
c['is_target'] = False
return jsonify({
"success": True,
"collection": collection,
"source": filename,
"total_chunks": total,
"target_index": None,
"chunks": preview_chunks
})
# 定位目标切片 —— 按 meta.chunk_index 排序后查找,避免数组下标与 chunk_index 不一致
try:
target_chunk_index = int(chunk_index_str)
except ValueError:
return jsonify({"error": "chunk_index 必须为整数"}), 400
# 按 chunk_index 排序Chroma 返回顺序不保证有序)
all_chunks.sort(key=lambda c: c.get('metadata', {}).get('chunk_index', 0))
# 按 meta.chunk_index 查找目标切片在排序后数组中的实际位置
target_pos = None
for i, c in enumerate(all_chunks):
if c.get('metadata', {}).get('chunk_index') == target_chunk_index:
target_pos = i
break
# 回退:直接用数组下标(兼容旧数据无 chunk_index 字段的情况)
if target_pos is None:
if 0 <= target_chunk_index < total:
target_pos = target_chunk_index
else:
max_idx = max(
(c.get('metadata', {}).get('chunk_index', 0) for c in all_chunks),
default=total - 1
)
return jsonify({
"error": f"chunk_index={target_chunk_index} 未找到对应切片 (可用范围 0-{max_idx})"
}), 400
# 截取上下文窗口
start = max(0, target_pos - context_count)
end = min(total, target_pos + context_count + 1)
window = all_chunks[start:end]
# 标记目标切片
for i, c in enumerate(window):
c['is_target'] = (start + i == target_pos)
return jsonify({
"success": True,
"collection": collection,
"source": filename,
"total_chunks": total,
"target_index": target_chunk_index,
"chunks": window
})
# ==================== 切片管理 ====================
@document_bp.route('/chunks', methods=['POST'])
@require_gateway_auth
def create_chunk() -> Tuple[Any, int]:
"""
新增切片
手动向向量库添加一个切片,用于补充或修正内容。
请求体:
{
"collection": "向量库名称",
"content": "切片内容",
"metadata": {} // 可选
}
Returns:
{"success": true, "chunk_id": "...", "message": "切片已添加"}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
data = request.json or {}
collection = data.get('collection')
content = data.get('content')
metadata = data.get('metadata', {})
if not collection:
return jsonify({"error": "请指定向量库 (collection)"}), 400
if not content:
return jsonify({"error": "切片内容不能为空"}), 400
chunk_id = kb_manager.add_chunk(collection, content, metadata)
return jsonify({
"success": True,
"chunk_id": chunk_id,
"message": "切片已添加"
})
@document_bp.route('/chunks/<chunk_id>', methods=['PUT'])
@require_gateway_auth
def update_chunk(chunk_id: str) -> Tuple[Any, int]:
"""
修改切片
更新指定切片的内容或元数据。
Args:
chunk_id: 切片 ID
请求体:
{
"collection": "向量库名称",
"content": "新内容", // 可选
"metadata": {} // 可选
}
Returns:
{"success": true, "message": "切片已更新"}
"""
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
data = request.json or {}
collection = data.get('collection')
content = data.get('content')
metadata = data.get('metadata')
if not collection:
return jsonify({"error": "请指定向量库 (collection)"}), 400
success = kb_manager.update_chunk(collection, chunk_id, content=content, metadata=metadata)
if success:
return jsonify({"success": True, "message": "切片已更新"})
return jsonify({"error": "更新失败"}), 500
@document_bp.route('/chunks/<chunk_id>', methods=['DELETE'])
@require_gateway_auth
def delete_chunk(chunk_id: str) -> Tuple[Any, int]:
"""
删除切片
从向量库中移除指定切片。如果删除后该文件没有其他切片,
同时清理文档哈希记录,以便同步服务能重新检测该文件。
Args:
chunk_id: 切片 ID
请求体或查询参数:
collection: 向量库名称(必需)
Returns:
{"success": true, "message": "切片已删除"}
"""
data = request.get_json(silent=True) or {}
collection = data.get('collection') if data else None
if not collection:
collection = request.args.get('collection')
if not collection:
return jsonify({"error": "请指定向量库 (collection)"}), 400
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 删除切片,返回 (success, source_file)
success, source_file = kb_manager.delete_chunk(collection, chunk_id)
if success and source_file:
# 检查该文件是否还有其他切片
remaining_chunks = kb_manager.list_chunks(collection, limit=100000)
has_other_chunks = any(
c.get('metadata', {}).get('source') == source_file
for c in remaining_chunks
)
# 如果没有其他切片了,删除哈希记录
if not has_other_chunks:
try:
from knowledge.sync import SyncDatabase
sync_db = SyncDatabase()
document_id = f"{collection}/{source_file}"
sync_db.delete_document_hash(document_id)
logger.info(f"清理文档哈希记录: {document_id}")
except Exception as e:
logger.warning(f"清理哈希记录失败: {e}")
if success:
return jsonify({"success": True, "message": "切片已删除"})
return jsonify({"error": "删除失败"}), 500
@document_bp.route('/chunks/batch', methods=['DELETE'])
@require_gateway_auth
def delete_chunks_by_source() -> Tuple[Any, int]:
"""
批量删除指定文件的所有切片
删除指定向量库中某个文件的所有切片,并清理哈希记录。
相比逐个删除,批量删除更高效,避免前端超时。
请求体:
{
"collection": "向量库名称",
"source": "文件名"
}
Returns:
{"success": true, "deleted_count": N, "message": "..."}
"""
data = request.get_json(silent=True) or {}
collection = data.get('collection')
source = data.get('source')
if not collection:
return jsonify({"error": "请指定向量库 (collection)"}), 400
if not source:
return jsonify({"error": "请指定文件名 (source)"}), 400
kb_manager = _get_kb_manager()
if not kb_manager:
return jsonify({"error": "知识库管理器未初始化"}), 503
# 批量删除该文件的所有切片
try:
deleted_count = kb_manager.delete_chunks_by_source(collection, source)
# 清理哈希记录
if deleted_count > 0:
try:
from knowledge.sync import SyncDatabase
sync_db = SyncDatabase()
# 统一使用正斜杠格式(与 sync.py scan_documents 一致)
document_id = f"{collection}/{source}"
sync_db.delete_document_hash(document_id)
logger.info(f"清理文档哈希记录: {document_id}")
except Exception as e:
logger.warning(f"清理哈希记录失败: {e}")
return jsonify({
"success": True,
"deleted_count": deleted_count,
"message": f"已删除 {deleted_count} 个切片"
})
except Exception as e:
logger.error(f"批量删除切片失败: {e}")
return jsonify({"error": str(e)}), 500

500
api/feedback_routes.py Normal file
View File

@@ -0,0 +1,500 @@
"""
问答质量闭环 API
路由:
- POST /feedback - 提交反馈
- GET /feedback/stats - 反馈统计
- GET /feedback/list - 反馈列表
- GET /feedback/bad-cases - Bad Case 分析(管理员)
- GET /feedback/blacklist - Chunk 黑名单(管理员)
- GET /reports/weekly - 周报告
- GET /reports/monthly - 月报告
- GET /faq - FAQ列表
- POST /faq - 新增FAQ (管理员,需二次确认)
- PUT /faq/<faq_id> - 更新FAQ (管理员)
- DELETE /faq/<faq_id> - 删除FAQ (管理员)
- POST /faq/<faq_id>/approve - 批准FAQ并同步知识库 (管理员)
- GET /faq/suggestions - FAQ建议列表 (管理员)
- POST /faq/suggestions/<id>/approve - 批准建议并同步知识库 (管理员)
- POST /faq/suggestions/<id>/reject - 拒绝建议 (管理员)
安全设计(二次确认机制):
所有 FAQ 入库都需要管理员二次确认,防止错误数据污染知识库:
1. 用户反馈 → FAQ 建议 (pending)
2. 管理员创建 → FAQ 草稿 (draft)
3. 二次确认 → 同步 ChromaDB (approved)
反馈飞轮机制:
1. 用户反馈 → 自动沉淀为 FAQ 建议(复合分数 > 0.5
2. 管理员批准 → FAQ 同步到 ChromaDB问题扩写 + 向量化)
3. 检索时 → FAQ 分数加权 + 时间衰减 + 黑名单过滤
4. LLM 生成 → FAQ 作为 Golden Context 融合回答
"""
from flask import Blueprint, request, jsonify
from auth.gateway import require_gateway_auth, require_role
feedback_bp = Blueprint('feedback', __name__)
# 延迟初始化:在 Blueprint 注册时通过 app.config 获取
_feedback_db = None
_feedback_service = None
def _get_feedback_db():
global _feedback_db
if _feedback_db is None:
from services.feedback import FeedbackDB
_feedback_db = FeedbackDB()
return _feedback_db
def _get_feedback_service():
global _feedback_service
if _feedback_service is None:
from services.feedback import FeedbackService
_feedback_service = FeedbackService(_get_feedback_db())
return _feedback_service
@feedback_bp.route('/feedback', methods=['POST'])
@require_gateway_auth
def submit_feedback():
"""提交反馈"""
data = request.get_json()
session_id = data.get('session_id')
query = data.get('query')
answer = data.get('answer')
rating = data.get('rating') # 1=赞, -1=踩
sources = data.get('sources', [])
reason = data.get('reason', '')
user_id = data.get('user_id', '')
if not session_id or not query or rating is None:
return jsonify({"error": "缺少必要参数"}), 400
if rating not in [1, -1]:
return jsonify({"error": "rating 必须是 1 或 -1"}), 400
try:
feedback_service = _get_feedback_service()
result = feedback_service.submit_feedback(
session_id=session_id,
query=query,
answer=answer or "",
rating=rating,
sources=sources,
reason=reason,
user_id=user_id
)
return jsonify({
"success": True,
"feedback_id": result['feedback_id'],
"faq_suggested": result.get('faq_suggested', False),
"suggestion_id": result.get('suggestion_id')
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/feedback/stats', methods=['GET'])
@require_gateway_auth
def get_feedback_stats():
"""获取反馈统计"""
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
try:
feedback_db = _get_feedback_db()
stats = feedback_db.get_feedback_stats(start_date, end_date)
return jsonify({
"success": True,
"stats": stats
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/feedback/list', methods=['GET'])
@require_gateway_auth
def get_feedback_list():
"""获取反馈列表"""
rating = request.args.get('rating', type=int)
user_id = request.args.get('user_id')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
limit = request.args.get('limit', 100, type=int)
try:
feedback_db = _get_feedback_db()
feedbacks = feedback_db.get_feedbacks(
rating=rating,
user_id=user_id,
start_date=start_date,
end_date=end_date,
limit=limit
)
return jsonify({
"success": True,
"feedbacks": feedbacks,
"total": len(feedbacks)
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/reports/weekly', methods=['GET'])
@require_gateway_auth
def get_weekly_report():
"""获取周报告"""
try:
feedback_service = _get_feedback_service()
report = feedback_service.generate_report("weekly")
return jsonify({
"success": True,
"report": report.to_dict()
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/reports/monthly', methods=['GET'])
@require_gateway_auth
def get_monthly_report():
"""获取月报告"""
try:
feedback_service = _get_feedback_service()
report = feedback_service.generate_report("monthly")
return jsonify({
"success": True,
"report": report.to_dict()
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/faq', methods=['GET'])
@require_gateway_auth
def get_faq_list():
"""获取FAQ列表"""
status = request.args.get('status')
limit = request.args.get('limit', 50, type=int)
try:
feedback_db = _get_feedback_db()
faqs = feedback_db.get_faqs(status=status, limit=limit)
return jsonify({
"success": True,
"faqs": faqs,
"total": len(faqs)
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/faq', methods=['POST'])
@require_gateway_auth
@require_role('admin')
def create_faq():
"""
新增FAQ管理员- 创建后需二次确认同步
安全设计:
1. 管理员创建的 FAQ 默认状态为 'draft'
2. 需要通过 /faq/<id>/approve 接口二次确认
3. 确认后才同步到 ChromaDB
"""
data = request.get_json()
question = data.get('question')
answer = data.get('answer')
if not question or not answer:
return jsonify({"error": "缺少问题或答案"}), 400
try:
from services.feedback import FAQ
feedback_db = _get_feedback_db()
# 管理员创建也进入 draft 状态,需要二次确认
faq = FAQ(
question=question,
answer=answer,
source_documents=data.get('source_documents', []),
status='draft' # 强制为 draft需要二次确认
)
faq_id = feedback_db.add_faq(faq)
return jsonify({
"success": True,
"faq_id": faq_id,
"status": "draft",
"message": "FAQ已创建请通过 /faq/<id>/approve 接口确认后生效"
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/faq/<int:faq_id>/approve', methods=['POST'])
@require_gateway_auth
@require_role('admin')
def approve_faq(faq_id):
"""
批准FAQ并同步到知识库管理员二次确认
适用于:
1. 管理员手动创建的 FAQ
2. 从 FAQ 建议转为正式的 FAQ
"""
try:
feedback_db = _get_feedback_db()
# 检查 FAQ 状态
faq = feedback_db.get_faq(faq_id)
if not faq:
return jsonify({"error": "FAQ不存在"}), 404
if faq.get('status') == 'approved':
return jsonify({"success": True, "message": "FAQ已经是批准状态"})
# 更新状态为 approved
feedback_db.update_faq(faq_id, {"status": "approved"})
# 同步到知识库
feedback_service = _get_feedback_service()
sync_success = feedback_service._sync_faq_to_knowledge_base(
faq_id=faq_id,
question=faq['question'],
answer=faq['answer']
)
return jsonify({
"success": True,
"faq_id": faq_id,
"sync_status": "synced" if sync_success else "sync_failed",
"message": "FAQ已批准并同步到知识库"
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/faq/<int:faq_id>', methods=['PUT'])
@require_gateway_auth
@require_role('admin')
def update_faq(faq_id):
"""更新FAQ管理员- 自动同步向量库"""
data = request.get_json()
try:
feedback_db = _get_feedback_db()
# 获取更新前的 FAQ 信息(用于判断是否需要重新同步)
old_faq = feedback_db.get_faq(faq_id)
if not old_faq:
return jsonify({"error": "FAQ不存在"}), 404
updated = feedback_db.update_faq(faq_id, data)
if not updated:
return jsonify({"error": "FAQ更新失败"}), 500
# 检查是否需要重新同步向量库question 或 answer 变更时)
need_sync = False
if data.get('question') and data['question'] != old_faq.get('question'):
need_sync = True
if data.get('answer') and data['answer'] != old_faq.get('answer'):
need_sync = True
sync_status = "skipped"
if need_sync and old_faq.get('status') == 'approved':
feedback_service = _get_feedback_service()
# 先删除旧向量
feedback_service._delete_faq_vectors(faq_id)
# 获取更新后的 FAQ 信息
updated_faq = feedback_db.get_faq(faq_id)
# 重新同步
sync_success = feedback_service._sync_faq_to_knowledge_base(
faq_id,
updated_faq['question'],
updated_faq['answer']
)
sync_status = "synced" if sync_success else "sync_failed"
return jsonify({
"success": True,
"message": "FAQ更新成功",
"sync_status": sync_status
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/faq/<int:faq_id>', methods=['DELETE'])
@require_gateway_auth
@require_role('admin')
def delete_faq(faq_id):
"""
删除FAQ管理员- 同步删除向量库数据
由于 FAQ 存储在独立的集合中,删除时可以精确清理,
不会影响普通文档向量库。
"""
try:
feedback_db = _get_feedback_db()
# 先获取 FAQ 信息(用于删除向量)
faq = feedback_db.get_faq(faq_id)
# 删除数据库记录
deleted = feedback_db.delete_faq(faq_id)
if deleted and faq:
# 同步删除向量库中的 FAQ 向量
feedback_service = _get_feedback_service()
feedback_service._delete_faq_vectors(faq_id)
return jsonify({
"success": deleted,
"message": "FAQ删除成功" if deleted else "FAQ不存在"
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/faq/suggestions', methods=['GET'])
@require_gateway_auth
@require_role('admin')
def get_faq_suggestions():
"""获取FAQ建议列表管理员"""
status = request.args.get('status', 'pending')
limit = request.args.get('limit', 50, type=int)
try:
feedback_db = _get_feedback_db()
suggestions = feedback_db.get_faq_suggestions(status=status, limit=limit)
return jsonify({
"success": True,
"suggestions": suggestions,
"total": len(suggestions)
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/approve', methods=['POST'])
@require_gateway_auth
@require_role('admin')
def approve_faq_suggestion(suggestion_id):
"""
批准FAQ建议并同步到知识库二次确认
请求体(可选):
{
"answer": "管理员修改后的答案"
}
"""
data = request.get_json() or {}
answer_override = data.get('answer')
try:
feedback_service = _get_feedback_service()
result = feedback_service.approve_and_sync_faq(
suggestion_id,
answer_override=answer_override
)
if result.get('success'):
return jsonify({
"success": True,
"faq_id": result['faq_id'],
"sync_status": result.get('sync_status'),
"message": "FAQ建议已批准并同步到知识库"
})
else:
return jsonify({"error": result.get('error', '批准失败')}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/reject', methods=['POST'])
@require_gateway_auth
@require_role('admin')
def reject_faq_suggestion(suggestion_id):
"""拒绝FAQ建议管理员"""
try:
feedback_db = _get_feedback_db()
rejected = feedback_db.reject_faq_suggestion(suggestion_id)
return jsonify({
"success": rejected,
"message": "FAQ建议已拒绝" if rejected else "建议不存在"
})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ==================== Bad Case 分析接口 ====================
@feedback_bp.route('/feedback/bad-cases', methods=['GET'])
@require_gateway_auth
@require_role('admin')
def get_bad_cases():
"""
获取负反馈 Bad Case 列表(管理员)
用于分析和改进 RAG 系统:
- 识别高频失败查询
- 发现知识库盲区
- 优化检索策略
"""
limit = request.args.get('limit', 20, type=int)
try:
feedback_service = _get_feedback_service()
# 获取低分问题
bad_cases = feedback_service.get_low_rating_queries(limit=limit)
# 获取黑名单来源
blacklisted_sources = feedback_service.get_low_rated_sources(min_count=3)
# 标记处理状态
for case in bad_cases:
case['status'] = 'pending' # pending/resolved/ignored
return jsonify({
"success": True,
"bad_cases": bad_cases,
"blacklisted_sources": blacklisted_sources,
"suggestions": [
"补充到知识库(针对知识盲区)",
"添加到 Query Rewrite 规则(针对表达歧义)",
"标记为知识库盲区(暂不处理)"
]
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@feedback_bp.route('/feedback/blacklist', methods=['GET'])
@require_gateway_auth
@require_role('admin')
def get_chunk_blacklist():
"""
获取 Chunk 黑名单(管理员)
返回被多次点踩的来源,用于在检索时降权或过滤
"""
min_dislikes = request.args.get('min_dislikes', 3, type=int)
try:
feedback_service = _get_feedback_service()
blacklist = feedback_service.get_chunk_blacklist(min_dislikes=min_dislikes)
return jsonify({
"success": True,
"blacklist": list(blacklist),
"count": len(blacklist),
"usage": "在检索时过滤这些来源以提升回答质量"
})
except Exception as e:
return jsonify({"error": str(e)}), 500

225
api/image_routes.py Normal file
View File

@@ -0,0 +1,225 @@
# -*- coding: utf-8 -*-
"""
图片服务 API
路由:
- GET /images/<image_id> - 获取图片
- GET /images/<image_id>/info - 获取图片信息
- GET /images/list - 列出所有图片
"""
import os
from flask import Blueprint, send_file, jsonify, current_app
image_bp = Blueprint('images', __name__)
def get_images_base_path():
"""获取图片存储路径(扁平化)"""
# 使用扁平化路径 .data/images
data_images_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".data", "images")
if os.path.exists(data_images_path):
return data_images_path
# 回退到 documents/images
try:
from config import DOCUMENTS_PATH
return os.path.join(DOCUMENTS_PATH, "images")
except ImportError:
return os.path.join(os.path.dirname(os.path.dirname(__file__)), "documents", "images")
@image_bp.route('/images/<image_id>', methods=['GET'])
def get_image(image_id: str):
"""
获取图片
Args:
image_id: 图片 ID支持多种格式xxx.jpg, images/xxx.jpg, /images/xxx.jpg
Returns:
图片文件
"""
# 统一提取文件名(处理各种路径格式)
image_id = os.path.basename(image_id)
# 去掉扩展名(后面会重新匹配)
image_id = os.path.splitext(image_id)[0]
# 安全检查:防止路径遍历攻击
if '..' in image_id or '/' in image_id or '\\' in image_id:
return jsonify({"error": "无效的图片 ID"}), 400
images_path = get_images_base_path()
# 支持多种格式
supported_formats = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']
for ext in supported_formats:
image_path = os.path.join(images_path, f"{image_id}{ext}")
if os.path.exists(image_path):
try:
mimetype = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp'
}.get(ext, 'image/octet-stream')
return send_file(image_path, mimetype=mimetype)
except Exception as e:
return jsonify({"error": f"读取图片失败: {str(e)}"}), 500
return jsonify({"error": "图片不存在", "image_id": image_id}), 404
@image_bp.route('/images/<image_id>/info', methods=['GET'])
def get_image_info(image_id: str):
"""
获取图片元信息
Args:
image_id: 图片 ID
Returns:
图片元信息(宽度、高度、格式等)
"""
# 统一提取文件名
image_id = os.path.basename(image_id)
image_id = os.path.splitext(image_id)[0]
# 安全检查
if '..' in image_id or '/' in image_id or '\\' in image_id:
return jsonify({"error": "无效的图片 ID"}), 400
images_path = get_images_base_path()
supported_formats = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']
for ext in supported_formats:
image_path = os.path.join(images_path, f"{image_id}{ext}")
if os.path.exists(image_path):
try:
# 使用 PIL 获取图片信息
from PIL import Image
with Image.open(image_path) as img:
return jsonify({
"image_id": image_id,
"width": img.width,
"height": img.height,
"format": img.format,
"mode": img.mode,
"size_bytes": os.path.getsize(image_path),
"url": f"/images/{image_id}"
})
except ImportError:
# PIL 未安装,返回基本信息
return jsonify({
"image_id": image_id,
"size_bytes": os.path.getsize(image_path),
"url": f"/images/{image_id}"
})
except Exception as e:
return jsonify({"error": f"读取图片信息失败: {str(e)}"}), 500
return jsonify({"error": "图片不存在", "image_id": image_id}), 404
@image_bp.route('/images/list', methods=['GET'])
def list_images():
"""
列出所有图片
Query Parameters:
limit: 最大返回数量(默认 50
offset: 偏移量(默认 0
Returns:
图片列表
"""
from flask import request
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
images_path = get_images_base_path()
if not os.path.exists(images_path):
return jsonify({"images": [], "total": 0})
supported_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
images = []
try:
for filename in os.listdir(images_path):
ext = os.path.splitext(filename)[1].lower()
if ext in supported_extensions:
image_id = os.path.splitext(filename)[0]
filepath = os.path.join(images_path, filename)
images.append({
"image_id": image_id,
"url": f"/images/{image_id}",
"size_bytes": os.path.getsize(filepath)
})
# 排序
images.sort(key=lambda x: x['image_id'])
# 分页
total = len(images)
images = images[offset:offset + limit]
return jsonify({
"images": images,
"total": total,
"limit": limit,
"offset": offset
})
except Exception as e:
return jsonify({"error": f"列出图片失败: {str(e)}"}), 500
@image_bp.route('/images/stats', methods=['GET'])
def image_stats():
"""
获取图片统计信息
Returns:
图片总数、总大小等统计信息
"""
images_path = get_images_base_path()
if not os.path.exists(images_path):
return jsonify({
"total_images": 0,
"total_size_bytes": 0,
"supported_formats": ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']
})
supported_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
total_count = 0
total_size = 0
format_counts = {}
try:
for filename in os.listdir(images_path):
ext = os.path.splitext(filename)[1].lower()
if ext in supported_extensions:
total_count += 1
filepath = os.path.join(images_path, filename)
total_size += os.path.getsize(filepath)
format_counts[ext] = format_counts.get(ext, 0) + 1
return jsonify({
"total_images": total_count,
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"format_counts": format_counts,
"supported_formats": list(supported_extensions)
})
except Exception as e:
return jsonify({"error": f"获取统计信息失败: {str(e)}"}), 500

881
api/kb_routes.py Normal file
View File

@@ -0,0 +1,881 @@
"""
多向量库管理 API
本模块提供向量库的 CRUD 操作和文档同步功能,支持:
- 创建、查询、修改、删除向量库
- 文档向量化同步
- 文档版本管理(废止/恢复)
- 知识库路由测试
路由列表:
GET /collections : 获取向量库列表
POST /collections : 创建向量库
PUT /collections/<kb_name> : 修改向量库信息
DELETE /collections/<kb_name> : 删除向量库
GET /collections/<kb_name>/documents : 获取向量库文档列表
GET /collections/<kb_name>/chunks : 获取向量库切片列表
POST /documents/sync : 触发文档同步
POST /kb/route : 测试知识库路由(调试)
文档版本管理:
POST /collections/<kb_name>/documents/<path:filename>/deprecate : 废止文档
POST /collections/<kb_name>/documents/<path:filename>/restore : 恢复文档
GET /collections/<kb_name>/documents/<path:filename>/versions : 版本历史
架构说明:
- 权限验证由后端网关完成RAG 服务不做权限判断
- 每个部门可拥有独立的向量库(多租户架构)
- 向量库元数据存储在 kb_metadata.json
Example:
curl -X GET http://localhost:5001/collections \\
-H "Authorization: Bearer mock-token-admin"
"""
import os
from typing import Tuple, Optional, Any
from flask import Blueprint, request, jsonify, current_app
import logging
logger = logging.getLogger(__name__)
from auth.gateway import require_gateway_auth
kb_bp = Blueprint('kb', __name__)
# 延迟初始化缓存
_kb_manager = None
_kb_router = None
_kb_checked = False
_has_multi_kb = False
def _check_multi_kb() -> bool:
"""
检查多向量库模块是否可用
延迟导入知识库管理器和路由器,避免循环依赖。
结果缓存在模块级变量中,后续调用直接返回缓存。
Returns:
多向量库可用返回 True否则返回 False
"""
global _kb_checked, _has_multi_kb, _kb_manager, _kb_router
if not _kb_checked:
try:
from knowledge.manager import get_kb_manager
from knowledge.router import get_kb_router
_kb_manager = get_kb_manager()
_kb_router = get_kb_router()
_has_multi_kb = True
except ImportError as e:
logger.warning(f"多向量库模块导入失败: {e}")
_has_multi_kb = False
_kb_checked = True
return _has_multi_kb
def _require_multi_kb() -> Tuple[Optional[Any], Optional[Any], Optional[Tuple]]:
"""
检查多向量库是否可用,返回管理器和路由器
Returns:
元组 (kb_manager, kb_router, error_response)
- 成功时 error_response 为 None
- 失败时 kb_manager 和 kb_router 为 None
Example:
>>> kb_manager, kb_router, err = _require_multi_kb()
>>> if err:
... return err
"""
if not _check_multi_kb():
return None, None, (jsonify({"error": "多向量库模块未启用"}), 503)
return _kb_manager, _kb_router, None
@kb_bp.route('/collections', methods=['GET'])
@require_gateway_auth
def list_collections() -> Tuple[Any, int]:
"""
获取向量库列表
返回当前用户可访问的所有向量库信息,包括:
- 向量库名称和显示名称
- 文档数量
- 所属部门
- 创建时间
- 描述信息
Returns:
JSON 响应,包含 collections 列表和 total 总数
Example:
GET /collections
Response: {"collections": [...], "total": 3}
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
user = request.current_user
# 获取所有向量库(权限由后端网关管理)
all_collections = kb_manager.list_collections()
result = []
for coll in all_collections:
result.append({
"name": coll.name,
"display_name": coll.display_name,
"document_count": coll.document_count,
"department": coll.department,
"created_at": coll.created_at,
"description": coll.description
})
return jsonify({
"collections": result,
"total": len(result)
})
@kb_bp.route('/collections', methods=['POST'])
@require_gateway_auth
def create_collection() -> Tuple[Any, int]:
"""
创建新向量库
请求体:
{
"name": "向量库标识(英文)",
"display_name": "显示名称",
"department": "所属部门",
"description": "描述信息"
}
注意:
- 名称只能包含字母、数字、下划线和连字符
- 名称不能为空
Returns:
成功: {"success": true, "message": "...", "name": "..."} (201)
失败: {"error": "..."} (400)
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
data = request.json or {}
name = data.get('name', '').strip()
display_name = data.get('display_name', '')
department = data.get('department', '')
description = data.get('description', '')
if not name:
return jsonify({"error": "向量库名称不能为空"}), 400
# 验证名称格式ChromaDB 限制)
if not name.replace('_', '').replace('-', '').isalnum():
return jsonify({
"error": "名称格式错误",
"message": "向量库名称只能包含字母、数字、下划线和连字符"
}), 400
success, message = kb_manager.create_collection(
name, display_name, department, description
)
if success:
return jsonify({"success": True, "message": message, "name": name}), 201
return jsonify({"error": message}), 400
@kb_bp.route('/collections/<kb_name>', methods=['PUT'])
@require_gateway_auth
def update_collection(kb_name: str) -> Tuple[Any, int]:
"""
修改向量库信息
Args:
kb_name: 向量库名称
请求体:
{
"display_name": "新显示名称",
"description": "新描述"
}
Returns:
成功: {"success": true, "message": "向量库信息已更新"}
失败: {"error": "..."} (404/500)
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
data = request.json or {}
display_name = data.get('display_name')
description = data.get('description')
# 检查向量库是否存在
collections = kb_manager.list_collections()
if not any(c.name == kb_name for c in collections):
return jsonify({"error": f"向量库 '{kb_name}' 不存在"}), 404
# 更新元数据
success = kb_manager.update_collection_metadata(
kb_name,
display_name=display_name,
description=description
)
if success:
return jsonify({"success": True, "message": "向量库信息已更新"})
return jsonify({"error": "更新失败"}), 500
@kb_bp.route('/collections/<kb_name>', methods=['DELETE'])
@require_gateway_auth
def delete_collection(kb_name: str) -> Tuple[Any, int]:
"""
删除向量库
Args:
kb_name: 向量库名称
查询参数:
delete_documents: 是否删除文档源文件(默认 false
Returns:
成功: {"success": true, "message": "...", "deleted_documents": bool}
失败: {"error": "..."} (400)
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
# 获取参数
delete_documents = request.args.get('delete_documents', 'false').lower() == 'true'
success, message = kb_manager.delete_collection(kb_name, delete_documents)
if success:
return jsonify({
"success": True,
"message": message,
"deleted_documents": delete_documents
})
return jsonify({"error": message}), 400
@kb_bp.route('/collections/<kb_name>/documents', methods=['GET'])
@require_gateway_auth
def list_collection_documents(kb_name: str) -> Tuple[Any, int]:
"""
获取向量库中的文档列表
Args:
kb_name: 向量库名称
Returns:
{"collection": "...", "documents": [...], "total": N}
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
documents = kb_manager.list_documents(kb_name)
return jsonify({
"collection": kb_name,
"documents": documents,
"total": len(documents)
})
@kb_bp.route('/collections/<kb_name>/chunks', methods=['GET'])
@require_gateway_auth
def list_collection_chunks(kb_name: str) -> Tuple[Any, int]:
"""
获取向量库中的切片列表
Args:
kb_name: 向量库名称
查询参数:
document_id: 过滤指定文档的切片(可选)
limit: 返回数量限制(默认 100
offset: 偏移量(默认 0
Returns:
{"collection": "...", "chunks": [...], "total": N}
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
# 可选过滤参数
document_id = request.args.get('document_id')
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
chunks = kb_manager.list_chunks(kb_name, document_id=document_id, limit=limit, offset=offset)
return jsonify({
"collection": kb_name,
"chunks": chunks,
"total": len(chunks)
})
@kb_bp.route('/documents/sync', methods=['POST'])
@require_gateway_auth
def sync_documents() -> Tuple[Any, int]:
"""
触发文档向量化同步
扫描文档目录,检测新增、修改、删除的文件,
自动更新向量库索引。
请求体:
{
"collection": "向量库名称" // 可选,不传则同步所有
}
Returns:
{
"success": true,
"results": [{"collection": "...", "status": "...", ...}],
"synced_count": N
}
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
from config import DOCUMENTS_PATH
user = request.current_user
data = request.json or {}
target_collection = data.get('collection')
# 确定要同步的向量库
if target_collection:
collections_to_sync = [target_collection]
else:
# 同步所有向量库
all_collections = kb_manager.list_collections()
collections_to_sync = [c.name for c in all_collections]
if not collections_to_sync:
return jsonify({"error": "没有可同步的向量库"}), 400
# 执行同步
results = []
# 使用 sync_service 执行同步
sync_service = current_app.config.get('SYNC_SERVICE')
if sync_service:
try:
sync_result = sync_service.sync_now()
results.append({
"collection": "all",
"status": "success",
"message": f"同步完成: 处理 {sync_result.documents_processed} 个文档",
"details": {
"added": sync_result.documents_added,
"modified": sync_result.documents_modified,
"deleted": sync_result.documents_deleted,
"errors": sync_result.errors
}
})
except Exception as e:
results.append({
"collection": "all",
"status": "error",
"message": str(e)
})
else:
# 没有 sync_service返回提示
for coll_name in collections_to_sync:
results.append({
"collection": coll_name,
"status": "warning",
"message": "同步服务不可用,请使用 POST /sync 端点"
})
return jsonify({
"success": True,
"results": results,
"synced_count": len([r for r in results if r["status"] == "success"])
})
@kb_bp.route('/debug/scan', methods=['GET'])
@require_gateway_auth
def debug_scan() -> Tuple[Any, int]:
"""
调试:查看同步服务扫描的文件
返回文档目录的文件列表和同步服务状态,
用于排查文档同步问题。
Returns:
{
"documents_path": "...",
"exists": bool,
"files": [...],
"sync_service_path": "...",
"scanned_count": N
}
"""
from config import DOCUMENTS_PATH
import os
result = {
"documents_path": DOCUMENTS_PATH,
"exists": os.path.exists(DOCUMENTS_PATH),
"files": []
}
if os.path.exists(DOCUMENTS_PATH):
for root, dirs, files in os.walk(DOCUMENTS_PATH):
for f in files:
fp = os.path.join(root, f)
rel = os.path.relpath(fp, DOCUMENTS_PATH)
result["files"].append({
"rel_path": rel,
"size": os.path.getsize(fp),
"ext": os.path.splitext(f)[1].lower()
})
# 也检查同步服务的路径
sync_service = current_app.config.get('SYNC_SERVICE')
if sync_service:
result["sync_service_path"] = sync_service.documents_path
result["sync_service_path_exists"] = os.path.exists(sync_service.documents_path)
# 直接调用 scan_documents
try:
scanned = sync_service.scan_documents()
result["scanned_count"] = len(scanned)
result["scanned_ids"] = list(scanned.keys())[:10]
except Exception as e:
result["scan_error"] = str(e)
return jsonify(result)
@kb_bp.route('/collections/<kb_name>/reindex', methods=['POST'])
@require_gateway_auth
def reindex_collection(kb_name: str) -> Tuple[Any, int]:
"""
强制重新向量化指定集合的所有文档
清除该集合的文档哈希记录,触发完整重新索引。
适用于文档内容更新后需要重建索引的场景。
Args:
kb_name: 向量库名称
Returns:
{
"success": true,
"message": "...",
"documents_processed": N,
"documents_added": N,
"errors": [...]
}
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
from config import DOCUMENTS_PATH
# 清除该集合的文档哈希记录
try:
from data.db import get_connection
with get_connection("knowledge") as conn:
cursor = conn.cursor()
# 删除以 "{kb_name}/" 或 "{kb_name}\" 开头的文档哈希(兼容 Windows 和 Linux
cursor.execute("DELETE FROM document_hashes WHERE document_id LIKE ? OR document_id LIKE ?",
(f"{kb_name}/%", f"{kb_name}\\%"))
deleted = cursor.rowcount
logger.info(f"已清除 {deleted} 条哈希记录: {kb_name}")
except Exception as e:
logger.warning(f"清除哈希记录失败: {e}")
# 触发同步
sync_service = current_app.config.get('SYNC_SERVICE')
if sync_service:
try:
result = sync_service.sync_now()
return jsonify({
"success": True,
"message": f"重新索引完成: 处理 {result.documents_processed} 个文档",
"documents_processed": result.documents_processed,
"documents_added": result.documents_added,
"errors": result.errors
})
except Exception as e:
return jsonify({"error": str(e)}), 500
else:
return jsonify({"error": "同步服务不可用"}), 503
@kb_bp.route('/kb/route', methods=['POST'])
@require_gateway_auth
def test_routing() -> Tuple[Any, int]:
"""
测试知识库路由(调试用)
分析查询意图,返回目标向量库列表和意图分析结果。
请求体:
{"query": "用户查询"}
Returns:
{
"query": "...",
"user_role": "...",
"user_department": "...",
"target_collections": [...],
"intent": {
"is_general": bool,
"department": "...",
"confidence": float,
"keywords": [...],
"reason": "..."
}
}
"""
_, kb_router, err = _require_multi_kb()
if err:
return err
from knowledge.router import route_query
user = request.current_user
data = request.json or {}
query = data.get('query', '')
if not query:
return jsonify({"error": "请提供查询内容"}), 400
# 获取路由结果
target_kbs = route_query(
query,
user.get("role", "user"),
user.get("department", "")
)
# 获取意图分析
intent = kb_router.analyze_intent(query)
return jsonify({
"query": query,
"user_role": user.get("role"),
"user_department": user.get("department", ""),
"target_collections": target_kbs,
"intent": {
"is_general": intent.is_general,
"department": intent.department,
"confidence": intent.confidence,
"keywords": intent.keywords,
"reason": intent.reason
}
})
# ==================== 文档版本管理 API ====================
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/deprecate', methods=['POST'])
@require_gateway_auth
def deprecate_document(kb_name: str, filename: str) -> Tuple[Any, int]:
"""
废止文档(软删除)
将文档标记为已废止,相关切片在检索时被过滤。
文档数据保留,可通过 restore_document 恢复。
Args:
kb_name: 向量库名称
filename: 文档文件名
请求体:
{"reason": "废止原因"}
Returns:
{
"success": true,
"deprecated_chunks": N,
"document_id": "...",
"collection": "...",
"deprecated_date": "ISO 8601 时间"
}
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
user = request.current_user
data = request.json or {}
reason = data.get('reason', '文档已废止')
try:
result = kb_manager.deprecate_document(
kb_name,
filename,
reason,
deprecated_by=user.get('user_id', 'unknown')
)
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/restore', methods=['POST'])
@require_gateway_auth
def restore_document(kb_name: str, filename: str) -> Tuple[Any, int]:
"""
恢复已废止的文档
将已废止的文档恢复为有效状态,相关切片重新参与检索。
Args:
kb_name: 向量库名称
filename: 文档文件名
Returns:
{
"success": true,
"restored_chunks": N,
"document_id": "...",
"collection": "..."
}
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
try:
result = kb_manager.restore_document(kb_name, filename)
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/versions', methods=['GET'])
@require_gateway_auth
def get_document_versions(kb_name: str, filename: str) -> Tuple[Any, int]:
"""
获取文档版本历史
返回文档的所有版本记录,包括状态变更、时间戳等信息。
Args:
kb_name: 向量库名称
filename: 文档文件名
查询参数:
limit: 返回数量限制(默认 10
Returns:
{
"success": true,
"document_id": "...",
"collection": "...",
"versions": [
{
"version": "v2",
"status": "active",
"created_at": "ISO 8601",
"chunk_count": N
},
...
],
"total": N
}
"""
_, _, err = _require_multi_kb()
if err:
return err
limit = request.args.get('limit', 10, type=int)
try:
from knowledge.document_versions import get_version_query
version_query = get_version_query()
versions = version_query.get_document_history(kb_name, filename, limit)
versions_data = [v.to_dict() for v in versions]
return jsonify({
"success": True,
"document_id": filename,
"collection": kb_name,
"versions": versions_data,
"total": len(versions_data)
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@kb_bp.route('/collections/<kb_name>/update-image-descriptions', methods=['POST'])
@require_gateway_auth
def update_image_descriptions(kb_name: str) -> Tuple[Any, int]:
"""
更新图片切片的轻量级描述
重新提取已入库文档中图片切片的图号/表号信息,
生成新的轻量级描述,提高检索准确度。
适用场景:
- 文档入库时未提取图号
- 图号提取规则更新后需要重新处理
Args:
kb_name: 向量库名称
Returns:
{
"success": true,
"image_count": N,
"updated_count": M
}
"""
kb_manager, _, err = _require_multi_kb()
if err:
return err
try:
result = kb_manager.update_image_descriptions(kb_name)
return jsonify(result)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@kb_bp.route('/collections/sync-vlm-cache', methods=['POST'])
@require_gateway_auth
def sync_vlm_cache() -> Tuple[Any, int]:
"""
同步 VLM 缓存到向量库元数据
将 .data/cache/vlm/ 中已生成的图片描述同步到向量库的 vlm_desc 字段。
同步后,检索时可直接从向量库获取 VLM 描述,无需读取文件系统。
请求参数:
- collection: 可选,指定同步的集合名,不传则同步所有集合
- dry_run: 可选,设为 true 只预览不执行
Returns:
{
"success": true,
"total_cache_files": N,
"synced_count": M,
"skipped_count": K,
"details": [...]
}
"""
from pathlib import Path
import hashlib
import json as json_module
kb_manager, _, err = _require_multi_kb()
if err:
return err
data = request.get_json() or {}
collection_filter = data.get('collection')
dry_run = data.get('dry_run', False)
vlm_cache_dir = Path(".data/cache/vlm")
images_dir = Path(".data/images")
if not vlm_cache_dir.exists():
return jsonify({"success": False, "error": "VLM 缓存目录不存在"}), 400
# 获取所有 VLM 缓存文件
cache_files = list(vlm_cache_dir.glob("*.txt"))
if not cache_files:
return jsonify({"success": True, "total_cache_files": 0, "synced_count": 0, "message": "无 VLM 缓存文件"})
# 构建图片 MD5 → 文件名 的映射
image_hash_map = {}
if images_dir.exists():
for img_file in images_dir.glob("*.png"):
img_hash = hashlib.md5(img_file.read_bytes()).hexdigest()
image_hash_map[img_hash] = img_file.name
# 结果统计
synced_count = 0
skipped_count = 0
details = []
# 获取所有集合
collections = kb_manager.list_collections()
if collection_filter:
collections = [c for c in collections if c.name == collection_filter]
for cache_file in cache_files:
cache_hash = cache_file.stem # 文件名即 MD5
# 查找对应的图片文件名
image_filename = image_hash_map.get(cache_hash)
if not image_filename:
skipped_count += 1
details.append({"cache": cache_file.name, "status": "skipped", "reason": "图片文件不存在"})
continue
# 读取 VLM 描述
vlm_desc = cache_file.read_text(encoding='utf-8')
# 在所有集合中查找该图片切片
found = False
for coll in collections:
collection = kb_manager.get_collection(coll.name)
if not collection:
continue
# 查找 image_path 匹配的切片
result = collection.get(where={"image_path": image_filename})
if result['ids']:
found = True
if not dry_run:
# 更新元数据
updated_metadatas = []
for meta in result['metadatas']:
meta['vlm_desc'] = vlm_desc
meta['has_vlm_desc'] = True
updated_metadatas.append(meta)
collection.update(
ids=result['ids'],
metadatas=updated_metadatas
)
synced_count += len(result['ids'])
details.append({
"cache": cache_file.name,
"image": image_filename,
"collection": coll.name,
"count": len(result['ids']),
"status": "synced" if not dry_run else "preview"
})
if not found:
skipped_count += 1
details.append({"cache": cache_file.name, "image": image_filename, "status": "skipped", "reason": "向量库中未找到对应切片"})
return jsonify({
"success": True,
"total_cache_files": len(cache_files),
"synced_count": synced_count,
"skipped_count": skipped_count,
"dry_run": dry_run,
"details": details[:20] # 只返回前 20 条详情
})

83
api/response_utils.py Normal file
View File

@@ -0,0 +1,83 @@
"""
统一响应格式工具
提供标准化的 API 响应格式:
- success_response: 成功响应
- error_response: 错误响应
所有响应自动包含 success 字段,保持向后兼容。
"""
from flask import jsonify
from typing import Any, Optional
from core.status_codes import get_status_message
def success_response(
data: Any = None,
status_code: int = 2000,
message: Optional[str] = None,
http_status: int = 200,
**extra_fields
):
"""
构造成功响应
Args:
data: 响应数据
status_code: 业务状态码 (默认 2000)
message: 自定义消息 (默认使用状态码对应描述)
http_status: HTTP 状态码 (默认 200)
**extra_fields: 额外字段
Returns:
Flask Response 对象
"""
response = {
"success": True,
"status": "success",
"status_code": status_code,
"message": message or get_status_message(status_code),
}
if data is not None:
response["data"] = data
# 添加额外字段
response.update(extra_fields)
return jsonify(response), http_status
def error_response(
error_code: str,
status_code: int,
message: str,
http_status: int = 400,
**extra_fields
):
"""
构造错误响应
Args:
error_code: 错误码 (如 "MISSING_PARAMS", "UNAUTHORIZED")
status_code: 业务状态码
message: 错误消息
http_status: HTTP 状态码 (默认 400)
**extra_fields: 额外字段
Returns:
Flask Response 对象
"""
response = {
"success": False,
"status": "failed",
"error_code": error_code,
"status_code": status_code,
"message": message,
}
# 添加额外字段
response.update(extra_fields)
return jsonify(response), http_status

114
api/session_routes.py Normal file
View File

@@ -0,0 +1,114 @@
"""
会话管理 API
路由:
- GET /sessions - 用户会话列表
- GET /history/<session_id> - 获取会话历史
- DELETE /session/<session_id> - 删除会话
- POST /clear/<session_id> - 清空会话历史
"""
from flask import Blueprint, request, jsonify, current_app
from auth.gateway import require_gateway_auth
session_bp = Blueprint('session', __name__)
@session_bp.route('/sessions', methods=['GET'])
@require_gateway_auth
def get_sessions():
"""
获取用户的会话列表
返回:
{
"sessions": [
{
"session_id": "...",
"created_at": "...",
"last_active": "...",
"preview": "最后一条消息预览..."
}
]
}
"""
session_manager = current_app.config['SESSION_MANAGER']
user_id = request.current_user["user_id"]
sessions = session_manager.get_user_sessions(user_id, limit=20)
# 添加最后一条消息预览
for s in sessions:
history = session_manager.get_history(s["session_id"], limit=1)
if history:
s["preview"] = history[0]["content"][:50] + "..."
else:
s["preview"] = "空会话"
return jsonify({"sessions": sessions})
@session_bp.route('/history/<session_id>', methods=['GET'])
@require_gateway_auth
def get_history(session_id):
"""
获取会话历史
返回:
{
"history": [
{"role": "user/assistant", "content": "...", "created_at": "..."}
]
}
"""
session_manager = current_app.config['SESSION_MANAGER']
user_id = request.current_user["user_id"]
# 验证会话归属
sessions = session_manager.get_user_sessions(user_id)
session_ids = [s["session_id"] for s in sessions]
if session_id not in session_ids:
return jsonify({"error": "无权访问此会话"}), 403
history = session_manager.get_history(session_id, limit=100)
return jsonify({"history": history})
@session_bp.route('/session/<session_id>', methods=['DELETE'])
@require_gateway_auth
def delete_session(session_id):
"""删除会话"""
session_manager = current_app.config['SESSION_MANAGER']
user_id = request.current_user["user_id"]
# 验证会话归属
sessions = session_manager.get_user_sessions(user_id)
session_ids = [s["session_id"] for s in sessions]
if session_id not in session_ids:
return jsonify({"error": "无权删除此会话"}), 403
session_manager.delete_session(session_id)
return jsonify({"success": True, "message": "会话已删除"})
@session_bp.route('/clear/<session_id>', methods=['POST'])
@require_gateway_auth
def clear_history(session_id):
"""清空会话历史(保留会话)"""
session_manager = current_app.config['SESSION_MANAGER']
user_id = request.current_user["user_id"]
# 验证会话归属
sessions = session_manager.get_user_sessions(user_id)
session_ids = [s["session_id"] for s in sessions]
if session_id not in session_ids:
return jsonify({"error": "无权操作此会话"}), 403
session_manager.clear_history(session_id)
return jsonify({"success": True, "message": "历史已清空"})

311
api/sync_routes.py Normal file
View File

@@ -0,0 +1,311 @@
"""
知识库同步 API
本模块提供文档同步服务的管理接口,包括:
- 手动触发同步
- 同步状态查询
- 文件监控控制
路由列表:
POST /sync : 手动触发同步
GET /sync/status : 获取同步状态
GET /sync/history : 同步历史记录
GET /sync/changes : 变更日志
POST /sync/start : 启动文件监控
POST /sync/stop : 停止文件监控
架构说明:
- 同步服务负责检测文档变更并自动向量化
- 订阅通知功能由后端服务负责
- 权限验证由后端网关完成
同步流程:
1. 扫描文档目录
2. 计算文件哈希,与历史记录对比
3. 检测新增、修改、删除的文件
4. 解析变更文件并更新向量库
Example:
curl -X POST http://localhost:5001/sync \\
-H "Authorization: Bearer mock-token-admin"
"""
from typing import Optional, Tuple, Any
from flask import Blueprint, request, jsonify, current_app
import logging
logger = logging.getLogger(__name__)
from auth.gateway import require_gateway_auth
from core.status_codes import SYNC_SUCCESS, SYNC_ERROR, INTERNAL_ERROR
from api.response_utils import success_response, error_response
sync_bp = Blueprint('sync', __name__)
def _get_sync_service() -> Optional[Any]:
"""
获取同步服务实例
Returns:
同步服务实例,不可用时返回 None
"""
try:
return current_app.config.get('SYNC_SERVICE')
except Exception as e:
logger.debug(f"获取同步服务失败: {e}")
return None
def _require_sync_service() -> Tuple[Optional[Any], Optional[Tuple]]:
"""
检查同步服务是否可用
Returns:
元组 (sync_service, error_response)
- 成功时 error_response 为 None
- 失败时 sync_service 为 None
"""
service = _get_sync_service()
if not service:
return None, error_response(
error="SERVICE_UNAVAILABLE",
error_code=INTERNAL_ERROR,
message="同步服务未启用",
http_status=503
)
return service, None
# ==================== 同步 API ====================
@sync_bp.route('/sync', methods=['POST'])
@require_gateway_auth
def trigger_sync() -> Tuple[Any, int]:
"""
手动触发知识库同步
扫描文档目录,检测变更并执行向量化处理。
请求体 (可选):
{
"collection": "向量库名称", // 可选,不传则同步所有
"full_sync": false // 是否全量同步
}
Returns:
成功: {"success": true, "data": {"result": {...}}}
失败: {"error": "...", "error_code": "..."}
Example:
curl -X POST http://localhost:5001/sync \\
-H "Authorization: Bearer mock-token-admin"
"""
service, err = _require_sync_service()
if err:
return err
try:
result = service.sync_now()
return success_response(
data={"result": result.to_dict() if hasattr(result, 'to_dict') else result},
status_code=SYNC_SUCCESS,
message="同步完成"
)
except Exception as e:
return error_response(
error="SYNC_ERROR",
error_code=SYNC_ERROR,
message=str(e),
http_status=500
)
@sync_bp.route('/sync/status', methods=['GET'])
@require_gateway_auth
def get_sync_status() -> Tuple[Any, int]:
"""
获取同步状态
返回同步服务的当前状态,包括:
- 是否启用
- 文件监控是否运行
- 最后同步时间
- 跟踪的文档数量
Returns:
{
"enabled": bool,
"monitoring": bool,
"last_sync": "ISO 8601",
"documents_tracked": N
}
"""
service, err = _require_sync_service()
if err:
return jsonify({
"status": "failed",
"status_code": INTERNAL_ERROR,
"enabled": False,
"message": "同步服务未启用"
})
try:
# 获取状态信息
status = {
"enabled": True,
"monitoring": service.is_running() if hasattr(service, 'is_running') else False,
"last_sync": None,
"documents_tracked": 0
}
# 尝试获取更多状态信息
if hasattr(service, 'get_status'):
status.update(service.get_status())
return jsonify(status)
except Exception as e:
return jsonify({
"status": "failed",
"status_code": INTERNAL_ERROR,
"enabled": True,
"error": str(e)
})
@sync_bp.route('/sync/history', methods=['GET'])
@require_gateway_auth
def get_sync_history() -> Tuple[Any, int]:
"""
获取同步历史
返回最近的同步操作记录。
查询参数:
limit: 返回数量限制(默认 20
Returns:
{"history": [...]}
"""
service, err = _require_sync_service()
if err:
return err
limit = request.args.get('limit', 20, type=int)
try:
history = service.get_sync_history(limit=limit) if hasattr(service, 'get_sync_history') else []
return jsonify({"history": history})
except Exception as e:
return error_response(
error="SYNC_ERROR",
error_code=SYNC_ERROR,
message=str(e),
http_status=500
)
@sync_bp.route('/sync/changes', methods=['GET'])
@require_gateway_auth
def get_change_logs() -> Tuple[Any, int]:
"""
获取变更日志
返回文档变更的详细记录,包括新增、修改、删除等操作。
查询参数:
limit: 返回数量限制(默认 50
collection: 过滤指定向量库(可选)
Returns:
{"changes": [...]}
"""
service, err = _require_sync_service()
if err:
return err
limit = request.args.get('limit', 50, type=int)
collection = request.args.get('collection')
try:
changes = service.get_change_logs(limit=limit, collection=collection) if hasattr(service, 'get_change_logs') else []
return jsonify({"changes": changes})
except Exception as e:
return error_response(
error="SYNC_ERROR",
error_code=SYNC_ERROR,
message=str(e),
http_status=500
)
@sync_bp.route('/sync/start', methods=['POST'])
@require_gateway_auth
def start_sync_monitor() -> Tuple[Any, int]:
"""
启动文件监控
启用实时文件监控,自动检测文档变更并触发同步。
适用于需要实时更新的场景。
Returns:
{"status": "success", "message": "文件监控已启动"}
Note:
文件监控会持续运行,直到调用 /sync/stop 或服务重启
"""
service, err = _require_sync_service()
if err:
return err
try:
if hasattr(service, 'is_running') and service.is_running():
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已在运行"})
if hasattr(service, 'start'):
success = service.start()
if success:
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已启动"})
else:
return error_response(
error="SYNC_ERROR",
error_code=SYNC_ERROR,
message="启动文件监控失败",
http_status=500
)
else:
return jsonify({"status": "success", "message": "文件监控功能不可用"})
except Exception as e:
return error_response(
error="SYNC_ERROR",
error_code=SYNC_ERROR,
message=str(e),
http_status=500
)
@sync_bp.route('/sync/stop', methods=['POST'])
@require_gateway_auth
def stop_sync_monitor() -> Tuple[Any, int]:
"""
停止文件监控
停止实时文件监控服务。已同步的数据保持不变。
Returns:
{"status": "success", "message": "文件监控已停止"}
"""
service, err = _require_sync_service()
if err:
return err
try:
if hasattr(service, 'stop'):
service.stop()
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已停止"})
except Exception as e:
return error_response(
error="SYNC_ERROR",
error_code=SYNC_ERROR,
message=str(e),
http_status=500
)