init: RAG 知识库服务初始提交
- 后端 API(Flask + Gunicorn) - RAG 引擎(混合检索 + 云端 Reranker + 引用溯源) - 文档解析(MinerU + 多格式支持) - Docker 生产部署配置 - 排除前端项目、敏感配置、模型文件
This commit is contained in:
260
api/__init__.py
Normal file
260
api/__init__.py
Normal 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
133
api/audit_routes.py
Normal 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
197
api/auth_routes.py
Normal 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
1998
api/chat_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
987
api/document_routes.py
Normal file
987
api/document_routes.py
Normal 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
500
api/feedback_routes.py
Normal 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
225
api/image_routes.py
Normal 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
881
api/kb_routes.py
Normal 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
83
api/response_utils.py
Normal 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
114
api/session_routes.py
Normal 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
311
api/sync_routes.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user