fix: restore main runtime sources and cache consistency

This commit is contained in:
User
2026-07-14 14:19:36 +08:00
parent e40989eeab
commit f951bc6598
15 changed files with 1664 additions and 29 deletions

26
.gitignore vendored
View File

@@ -59,8 +59,8 @@ CLAUDE.md
# 会话数据库(含用户数据) # 会话数据库(含用户数据)
sessions.db sessions.db
# 测试文件 # pytest / 测试运行缓存(测试源码需要纳入版本库)
tests/ .pytest_cache/
# 文档源文件(可能含敏感内容) # 文档源文件(可能含敏感内容)
documents/ documents/
@@ -81,13 +81,20 @@ test_output/
# 非必要文档 # 非必要文档
docs/superpowers/ docs/superpowers/
# 以下为v5.0.0新增忽略:多向量库存储及SQLite数据 # 多向量库存储及 SQLite 运行数据
data/ # data/db.py 与 data/__init__.py 是应用源码,必须纳入版本库;仅忽略数据库文件。
data/prod/
data/dev/
data/sqlite/
data/**/*.db
data/**/*.db-shm
data/**/*.db-wal
vector_store/ vector_store/
knowledge/vector_store/ knowledge/vector_store/
# 缓存和临时数据目录 # 缓存和临时数据目录
.data/ .data/
.recovery/
# 测试JSON文件 # 测试JSON文件
exam_questions.json exam_questions.json
@@ -114,14 +121,13 @@ chat-ui/
# 生产环境配置(含 API 密钥) # 生产环境配置(含 API 密钥)
deploy/.env.production deploy/.env.production
# 根目录临时测试脚本 # 根目录临时测试脚本tests/ 下的正式回归测试不忽略)
test_*.py /test_*.py
test_*.json /test_*.json
rag_response.json rag_response.json
nul nul
# 调试脚本和临时计划(仅本地使用 # 临时计划(正式 scripts/ 源码需要纳入版本库
scripts/
plans/ plans/
# Qoder 工具目录 # Qoder 工具目录
@@ -143,6 +149,8 @@ docs/环境分离说明.md
# 临时脚本 # 临时脚本
scripts/evaluate_enum_context.py scripts/evaluate_enum_context.py
scripts/migrate_split_databases.py scripts/migrate_split_databases.py
/scripts/test_cache*.py
/scripts/test_intent*.py
# 上传清单(仅本地使用) # 上传清单(仅本地使用)
UPLOAD_CHECKLIST.md UPLOAD_CHECKLIST.md

View File

@@ -9,11 +9,13 @@ import os
# 一、API 密钥与模型 # 一、API 密钥与模型
# ============================================================================== # ==============================================================================
# 通义千问 LLM 服务 # OpenAI 兼容 LLM 服务(当前默认使用小米 MiMo
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "") DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
DASHSCOPE_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1") DASHSCOPE_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1")
DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "qwen-flash") # 文本生成模型 DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "mimo-v2.5") # 文本生成模型
RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "qwen-flash") # RAG 对话模型 RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "mimo-v2.5") # RAG 对话模型
INTENT_MODEL = os.getenv("INTENT_MODEL", "mimo-v2.5") # 意图分析模型
VLM_MODEL = os.getenv("VLM_MODEL", "mimo-v2.5") # 图片理解模型
# 兼容旧变量名(逐步迁移到 DASHSCOPE_* 命名) # 兼容旧变量名(逐步迁移到 DASHSCOPE_* 命名)
API_KEY = DASHSCOPE_API_KEY API_KEY = DASHSCOPE_API_KEY
@@ -27,6 +29,7 @@ MODEL = DASHSCOPE_MODEL
APP_ENV = os.getenv("APP_ENV", "dev") # dev / prod APP_ENV = os.getenv("APP_ENV", "dev") # dev / prod
IS_DEV = APP_ENV == "dev" IS_DEV = APP_ENV == "dev"
IS_PROD = APP_ENV == "prod" IS_PROD = APP_ENV == "prod"
DEV_MODE = os.getenv("DEV_MODE", "true").lower() != "false"
# 开发/生产环境自动切换 # 开发/生产环境自动切换
ENABLE_SESSION = IS_DEV # 会话存储(仅开发环境) ENABLE_SESSION = IS_DEV # 会话存储(仅开发环境)
@@ -85,10 +88,11 @@ RERANK_DEVICE = os.getenv("RERANK_DEVICE", os.getenv("DEVICE", "auto"))
# ----- 通用问答 ----- # ----- 通用问答 -----
LLM_TEMPERATURE = 0.7 # 生成温度0=确定性1=随机性) LLM_TEMPERATURE = 0.7 # 生成温度0=确定性1=随机性)
LLM_MAX_TOKENS = 3000 # 最大输出 token 数 LLM_MAX_TOKENS = 3000 # 最大输出 token 数
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() != "false"
# ----- 意图分析(轻量、确定性高)----- # ----- 意图分析(轻量、确定性高)-----
INTENT_TEMPERATURE = 0.1 INTENT_TEMPERATURE = 0.1
INTENT_MAX_TOKENS = 300 INTENT_MAX_TOKENS = 2048
INTENT_HISTORY_WINDOW = 6 # 分析时取最近几条历史消息 INTENT_HISTORY_WINDOW = 6 # 分析时取最近几条历史消息
# ============================================================================== # ==============================================================================
@@ -201,6 +205,9 @@ MAX_QUERY_REWRITES = 1
# MinerU 解析器 # MinerU 解析器
MINERU_DEVICE_MODE = os.getenv("MINERU_DEVICE_MODE", "cpu") # cpu / cuda MINERU_DEVICE_MODE = os.getenv("MINERU_DEVICE_MODE", "cpu") # cpu / cuda
MINERU_MODEL_VERSION = os.getenv("MINERU_MODEL_VERSION", "vlm")
MINERU_LOCAL_BACKEND = os.getenv("MINERU_LOCAL_BACKEND", "pipeline")
MINERU_ONLINE_TIMEOUT = int(os.getenv("MINERU_ONLINE_TIMEOUT", "300"))
# MinerU 在线 API作为本地解析失败时的备选 # MinerU 在线 API作为本地解析失败时的备选
MINERU_API_TOKEN = os.getenv("MINERU_API_TOKEN", "") # 在 https://mineru.net/apiManage/token 申请 MINERU_API_TOKEN = os.getenv("MINERU_API_TOKEN", "") # 在 https://mineru.net/apiManage/token 申请
@@ -246,6 +253,20 @@ CONFIDENCE_CAUTION_THRESHOLD = 0.30 # top-3 均分低于此值时,提示 LLM
ENUM_QUERY_DISABLE_TOPK_SHRINK = True ENUM_QUERY_DISABLE_TOPK_SHRINK = True
ENUM_QUERY_MMR_LAMBDA = 0.85 ENUM_QUERY_MMR_LAMBDA = 0.85
# 章节聚类提升与 BM25/CrossEncoder 分歧救援
SECTION_CLUSTER_BOOST_ENABLED = True
SECTION_CLUSTER_RESCUE_ENABLED = True
BM25_DIVERGENCE_RESCUE_ENABLED = True
BM25_DIVERGENCE_MAX_RANK = 3
CLUSTER_MIN_MEMBERS = 3
CLUSTER_MIN_TYPES = 2
CLUSTER_SEED_FLOOR = 0.35
CLUSTER_RESCUE_FLOOR = 0.06
CLUSTER_MAX_BOOST_PER_SECTION = 8
CLUSTER_MAX_SECTIONS = 3
CLUSTER_MAX_RESCUE_PER_SECTION = 6
CLUSTER_SECTION_PREFIX_LEVELS = 2
# ============================================================================== # ==============================================================================
# 十一、可选功能配置 # 十一、可选功能配置
# ============================================================================== # ==============================================================================
@@ -261,3 +282,20 @@ def get_llm_client():
"""获取 LLM 客户端实例""" """获取 LLM 客户端实例"""
from openai import OpenAI from openai import OpenAI
return OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL) return OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL)
_intent_client = None
def get_intent_client():
"""获取意图分析专用 LLM 客户端(进程内复用连接池)。"""
global _intent_client
if _intent_client is None:
if not DASHSCOPE_API_KEY:
raise ValueError("DASHSCOPE_API_KEY 未配置,请在环境变量中设置")
from openai import OpenAI
_intent_client = OpenAI(
api_key=DASHSCOPE_API_KEY,
base_url=DASHSCOPE_BASE_URL,
)
return _intent_client

View File

@@ -189,6 +189,9 @@ class RAGCacheManager:
# 失效旧版本缓存 # 失效旧版本缓存
self.query_cache.invalidate_by_version(old_version) self.query_cache.invalidate_by_version(old_version)
self.embedding_cache.invalidate_by_version(old_version) self.embedding_cache.invalidate_by_version(old_version)
# Rerank 缓存未携带知识库版本;文档变更后必须全量清空,
# 否则相同 doc_id 可能继续复用旧文档内容对应的分数。
self.rerank_cache.clear()
logger.info(f"知识库 {kb_name} 版本更新: {old_version} -> {new_version}") logger.info(f"知识库 {kb_name} 版本更新: {old_version} -> {new_version}")
return new_version return new_version

View File

@@ -316,8 +316,8 @@ class IntentAnalyzer:
if cache_emb is not None: if cache_emb is not None:
cached = cache.get(cache_emb) cached = cache.get(cache_emb)
# 确保缓存条目是意图分析结果(非 RAG 回答缓存) # 仅接受明确标记的意图缓存,避免未来新增缓存类型时交叉命中。
if cached and cached.get("cache_type") != "rag_answer": if cached and cached.get("cache_type") == "intent_analysis":
logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}") logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}")
return IntentAnalysis.from_dict(cached) return IntentAnalysis.from_dict(cached)
else: else:

46
data/__init__.py Normal file
View File

@@ -0,0 +1,46 @@
"""
数据目录
统一数据库结构(按业务域和运行环境划分):
- data/prod/feedback.db: 反馈、FAQ 与质量报告
- data/prod/knowledge.db: 同步、纲要与文档版本
- data/dev/session.db: 本地开发会话与审计日志
- data/dev/exam.db: 本地出题、试卷与批阅数据
使用方式:
from data.db import get_connection, init_databases
# 首次运行时初始化
init_databases()
# 使用连接
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM sessions")
"""
from data.db import (
get_connection,
get_raw_connection,
init_databases,
get_db_stats,
row_to_dict,
rows_to_list,
json_parse,
json_stringify,
DB_PATHS,
DATA_DIR,
)
__all__ = [
"get_connection",
"get_raw_connection",
"init_databases",
"get_db_stats",
"row_to_dict",
"rows_to_list",
"json_parse",
"json_stringify",
"DB_PATHS",
"DATA_DIR",
]

821
data/db.py Normal file
View File

@@ -0,0 +1,821 @@
"""
统一数据访问层 - 集中管理所有数据库连接
功能:
1. 统一数据库路径配置按环境分离prod/dev
2. 连接池管理(上下文管理器)
3. WAL 模式 + 外键约束
4. 自动事务管理commit/rollback
5. 初始化所有表结构
数据库分离:
- feedback.db: 反馈系统(生产模式也使用)
- knowledge.db: 知识管理(生产模式也使用)
- session.db: 会话管理(仅开发模式)
- exam.db: 出题系统(仅开发模式)
使用方式:
from data.db import get_connection, init_databases
# 初始化数据库(首次运行时调用)
init_databases()
# 使用连接
with get_connection("feedback") as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM feedbacks WHERE user_id = ?", (user_id,))
rows = cursor.fetchall()
"""
import sqlite3
import os
import json
import logging
import threading
from contextlib import contextmanager
from typing import Generator, Dict, List, Optional
logger = logging.getLogger(__name__)
# ==================== 路径配置 ====================
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATHS = {
# 生产模式数据库(开发模式也使用)
"feedback": os.path.join(DATA_DIR, "prod", "feedback.db"),
"knowledge": os.path.join(DATA_DIR, "prod", "knowledge.db"),
# 仅开发模式数据库
"session": os.path.join(DATA_DIR, "dev", "session.db"),
"exam": os.path.join(DATA_DIR, "dev", "exam.db"),
}
# 数据库名称映射(用于日志)
DB_NAMES = {
"feedback": "反馈系统数据库反馈、FAQ、质量报告",
"knowledge": "知识管理数据库(同步、大纲、版本)",
"session": "会话管理数据库(会话、消息、审计日志)",
"exam": "出题系统数据库(题库、试卷、批卷)",
}
_init_lock = threading.Lock()
_initialized = False
# ==================== 连接管理 ====================
@contextmanager
def get_connection(db_name: str, row_factory: bool = True) -> Generator[sqlite3.Connection, None, None]:
"""
获取数据库连接(上下文管理器)
Args:
db_name: 数据库名称 ("feedback", "knowledge", "session", "exam")
row_factory: 是否启用行工厂(返回字典格式)
Yields:
sqlite3.Connection: 数据库连接
Example:
with get_connection("feedback") as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM feedbacks")
rows = cursor.fetchall()
"""
if db_name not in DB_PATHS:
raise ValueError(f"未知的数据库: {db_name},可用: {list(DB_PATHS.keys())}")
db_path = DB_PATHS[db_name]
# fresh clone 中 prod/dev 目录尚不存在;连接前统一创建父目录。
os.makedirs(os.path.dirname(db_path), exist_ok=True)
# 关键check_same_thread=False 支持多worker
conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
# 启用 WAL 模式(提升并发性能)
conn.execute("PRAGMA journal_mode=WAL")
# 设置繁忙超时5秒
conn.execute("PRAGMA busy_timeout=5000")
# 启用外键约束
conn.execute("PRAGMA foreign_keys=ON")
# 设置行工厂(返回字典格式)
if row_factory:
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_raw_connection(db_name: str) -> sqlite3.Connection:
"""
获取原始数据库连接(不使用上下文管理器)
注意:调用者需要手动关闭连接
Args:
db_name: 数据库名称
Returns:
sqlite3.Connection: 数据库连接
"""
if db_name not in DB_PATHS:
raise ValueError(f"未知的数据库: {db_name}")
db_path = DB_PATHS[db_name]
os.makedirs(os.path.dirname(db_path), exist_ok=True)
conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn
# ==================== 数据库初始化 ====================
def init_databases():
"""
初始化所有数据库表结构
按环境分离为:
- 生产模式数据库开发模式也使用feedback.db, knowledge.db
- 仅开发模式数据库session.db, exam.db
"""
global _initialized
if _initialized:
return
from config import IS_DEV
with _init_lock:
if _initialized:
return
# 始终初始化生产模式数据库
_init_feedback_db()
_init_knowledge_db()
# 仅开发模式初始化会话和出题数据库
if IS_DEV:
_init_session_db()
_init_exam_db()
logger.info("所有数据库初始化完成(开发模式)")
else:
logger.info("生产模式数据库初始化完成")
_initialized = True
def _init_feedback_db():
"""初始化 feedback.db反馈系统数据库 - 生产模式也使用)"""
with get_connection("feedback", row_factory=False) as conn:
cursor = conn.cursor()
# ========== 反馈表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS feedbacks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
query TEXT NOT NULL,
answer TEXT,
sources TEXT,
rating INTEGER NOT NULL,
reason TEXT,
user_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_feedback_session
ON feedbacks(session_id)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_feedback_rating
ON feedbacks(rating)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_feedback_created
ON feedbacks(created_at)
''')
# ========== FAQ 表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS faqs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT NOT NULL,
answer TEXT NOT NULL,
source_documents TEXT,
frequency INTEGER DEFAULT 1,
avg_rating REAL DEFAULT 0,
status TEXT DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_faq_status
ON faqs(status)
''')
# ========== FAQ 问题变体表Multi-Query Indexing==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS faq_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
faq_id INTEGER NOT NULL,
variant_question TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (faq_id) REFERENCES faqs(id) ON DELETE CASCADE
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_faq_variant_faq
ON faq_variants(faq_id)
''')
# ========== 质量报告表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS quality_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_type TEXT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
total_queries INTEGER DEFAULT 0,
total_feedback INTEGER DEFAULT 0,
positive_count INTEGER DEFAULT 0,
negative_count INTEGER DEFAULT 0,
avg_rating REAL DEFAULT 0,
satisfaction_rate REAL DEFAULT 0,
high_freq_queries TEXT,
low_rating_queries TEXT,
improvement_suggestions TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# ========== FAQ 建议表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS faq_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
answer TEXT,
frequency INTEGER DEFAULT 1,
avg_rating REAL DEFAULT 0,
status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_faq_suggestion_status
ON faq_suggestions(status)
''')
logger.info(f"初始化数据库: {DB_PATHS['feedback']} (feedback.db)")
def _init_session_db():
"""初始化 session.db会话管理数据库 - 仅开发模式使用)"""
with get_connection("session", row_factory=False) as conn:
cursor = conn.cursor()
# ========== 会话表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_sessions_user
ON sessions(user_id)
''')
# ========== 消息表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
metadata TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, created_at)
''')
# ========== 审计日志表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
username TEXT DEFAULT '',
action TEXT NOT NULL,
query TEXT,
result_summary TEXT,
sources TEXT,
role TEXT,
department TEXT,
ip_address TEXT,
duration_ms INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_audit_user
ON audit_logs(user_id, created_at)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_audit_action
ON audit_logs(action, created_at)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_audit_created
ON audit_logs(created_at)
''')
logger.info(f"初始化数据库: {DB_PATHS['session']} (session.db)")
def _init_knowledge_db():
"""初始化 knowledge.db知识管理数据库"""
with get_connection("knowledge", row_factory=False) as conn:
cursor = conn.cursor()
# ========== 文档哈希表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS document_hashes (
document_id TEXT PRIMARY KEY,
document_name TEXT,
content_hash TEXT,
file_size INTEGER,
last_modified TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# ========== 变更日志表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS change_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT,
document_name TEXT,
change_type TEXT,
old_hash TEXT,
new_hash TEXT,
change_time TIMESTAMP,
processed INTEGER DEFAULT 0,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_change_logs_time
ON change_logs(change_time)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_change_logs_processed
ON change_logs(processed)
''')
# ========== 同步状态表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS sync_status (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sync_type TEXT,
status TEXT,
start_time TIMESTAMP,
end_time TIMESTAMP,
documents_processed INTEGER,
documents_added INTEGER,
documents_modified INTEGER,
documents_deleted INTEGER,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# ========== 纲要缓存表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS outline_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL UNIQUE,
document_name TEXT,
total_pages INTEGER DEFAULT 0,
content_hash TEXT NOT NULL,
outline_json TEXT NOT NULL,
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_outline_doc
ON outline_cache(document_id)
''')
# ========== 文档向量缓存表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS document_vectors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL UNIQUE,
document_name TEXT,
vector_hash TEXT,
vector_json TEXT NOT NULL,
tags_json TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_vector_doc
ON document_vectors(document_id)
''')
# ========== 推荐缓存表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS recommendation_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
recommendations_json TEXT NOT NULL,
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# ========== 文档版本表(统一结构) ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS document_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
collection TEXT,
version TEXT NOT NULL DEFAULT 'v1',
content_hash TEXT,
status TEXT NOT NULL DEFAULT 'active',
effective_date DATE,
expiry_date DATE,
deprecated_date DATETIME,
deprecated_reason TEXT,
deprecated_by TEXT,
change_summary TEXT,
changed_sections TEXT,
supersedes TEXT,
chunk_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by TEXT,
UNIQUE(document_id, collection, version)
)
''')
# ========== 版本变更日志表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS version_change_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
collection TEXT,
old_version TEXT,
new_version TEXT,
old_status TEXT,
new_status TEXT,
change_type TEXT NOT NULL,
reason TEXT,
changed_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 添加性能优化索引
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_document_versions_status
ON document_versions(document_id, collection, status)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_version_change_logs_document
ON version_change_logs(document_id, collection, created_at DESC)
''')
logger.info(f"初始化数据库: {DB_PATHS['knowledge']} (knowledge.db)")
def _init_exam_db():
"""初始化 exam.db出题系统数据库"""
with get_connection("exam", row_factory=False) as conn:
cursor = conn.cursor()
# ========== 题目表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
question_type TEXT NOT NULL,
content TEXT NOT NULL,
options TEXT,
correct_answer TEXT NOT NULL,
analysis TEXT,
knowledge_points TEXT,
difficulty INTEGER DEFAULT 3,
score INTEGER NOT NULL,
source_file TEXT NOT NULL,
source_collection TEXT NOT NULL,
source_snippet TEXT,
source_hash TEXT,
status TEXT DEFAULT 'approved',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_questions_source
ON questions(source_file)
''')
# ========== 试卷表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS exams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
total_score INTEGER NOT NULL,
total_count INTEGER NOT NULL,
duration INTEGER DEFAULT 60,
status TEXT DEFAULT 'published',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by TEXT
)
''')
# ========== 试卷题目关联表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS exam_questions (
exam_id TEXT NOT NULL,
question_id TEXT NOT NULL,
question_order INTEGER NOT NULL,
PRIMARY KEY (exam_id, question_id),
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
)
''')
# ========== 学生答卷表(只存 student_id ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS student_answers (
id TEXT PRIMARY KEY,
exam_id TEXT NOT NULL,
student_id TEXT NOT NULL,
question_id TEXT NOT NULL,
question_type TEXT NOT NULL,
student_answer TEXT NOT NULL,
score REAL DEFAULT 0,
max_score INTEGER NOT NULL,
feedback TEXT,
score_details TEXT,
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
graded_at TIMESTAMP,
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_student_answers_exam
ON student_answers(exam_id, student_id)
''')
# ========== 批阅报告表(只存 student_id ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS grade_reports (
id TEXT PRIMARY KEY,
exam_id TEXT NOT NULL,
student_id TEXT NOT NULL,
total_score REAL NOT NULL,
max_score REAL NOT NULL,
score_rate REAL,
analysis TEXT,
graded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE
)
''')
# ========== 题目-制度关联表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS question_document_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id TEXT NOT NULL,
question_type TEXT NOT NULL,
exam_id TEXT NOT NULL,
document_id TEXT NOT NULL,
document_name TEXT,
chapter TEXT,
key_points TEXT,
relevance_score REAL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_qdl_question
ON question_document_links(question_id)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_qdl_document
ON question_document_links(document_id)
''')
# ========== 知识点表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS knowledge_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
category TEXT,
description TEXT,
parent_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES knowledge_points(id)
)
''')
# ========== 题目-知识点关联表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS question_knowledge_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id TEXT NOT NULL,
question_type TEXT NOT NULL,
exam_id TEXT NOT NULL,
knowledge_point_id INTEGER NOT NULL,
knowledge_point_name TEXT,
weight REAL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (knowledge_point_id) REFERENCES knowledge_points(id)
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_qkl_question
ON question_knowledge_links(question_id)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_qkl_knowledge
ON question_knowledge_links(knowledge_point_id)
''')
# ========== 题目状态表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS question_status (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id TEXT NOT NULL UNIQUE,
question_type TEXT NOT NULL,
exam_id TEXT NOT NULL,
status TEXT DEFAULT 'approved',
affected_by TEXT,
affect_reason TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_qs_status
ON question_status(status)
''')
# ========== 整卷分析报告表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS exam_analysis_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL UNIQUE,
exam_id TEXT,
exam_name TEXT,
student_id TEXT,
total_score REAL,
max_score REAL,
score_rate REAL,
type_scores TEXT,
knowledge_analysis TEXT,
weak_points TEXT,
strong_points TEXT,
ai_comment TEXT,
study_suggestions TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# ========== 新题建议表 ==========
cursor.execute('''
CREATE TABLE IF NOT EXISTS question_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
suggestion TEXT,
status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
logger.info(f"初始化数据库: {DB_PATHS['exam']} (exam.db)")
# ==================== 工具函数 ====================
def row_to_dict(row: sqlite3.Row) -> Dict:
"""将 sqlite3.Row 转换为字典"""
if row is None:
return {}
return dict(row)
def rows_to_list(rows: List[sqlite3.Row]) -> List[Dict]:
"""将 sqlite3.Row 列表转换为字典列表"""
return [row_to_dict(row) for row in rows]
def json_parse(value: str, default=None):
"""安全解析 JSON 字符串"""
if not value:
return default
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return default
def json_stringify(value, ensure_ascii: bool = False) -> str:
"""将对象转换为 JSON 字符串"""
if value is None:
return None
return json.dumps(value, ensure_ascii=ensure_ascii)
def get_db_stats() -> Dict:
"""获取所有数据库的统计信息"""
stats = {}
for db_name, db_path in DB_PATHS.items():
if os.path.exists(db_path):
file_size = os.path.getsize(db_path)
with get_connection(db_name, row_factory=False) as conn:
cursor = conn.cursor()
# 获取所有表名
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = [row[0] for row in cursor.fetchall()]
# 统计每个表的行数
table_counts = {}
for table in tables:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
table_counts[table] = cursor.fetchone()[0]
stats[db_name] = {
"path": db_path,
"name": DB_NAMES.get(db_name, db_name),
"file_size": file_size,
"tables": table_counts,
"total_rows": sum(table_counts.values())
}
else:
stats[db_name] = {
"path": db_path,
"name": DB_NAMES.get(db_name, db_name),
"exists": False
}
return stats
# ==================== 自动初始化 ====================
# 模块加载时不自动初始化,由调用方决定何时初始化
# 这样可以避免在 import 时创建数据库文件

View File

@@ -1032,6 +1032,7 @@ def analyze_document_for_exam(chunks: List[Dict], max_total: int = None) -> Dict
) )
prompt += f"\n### {section_name[:30]}\n{content_preview[:300]}\n" prompt += f"\n### {section_name[:30]}\n{content_preview[:300]}\n"
question_limit = min(total_knowledge_points * 2, max_total if max_total else 20)
prompt += """ prompt += """
## 要求 ## 要求
根据文档内容特点,决定: 根据文档内容特点,决定:
@@ -1055,10 +1056,10 @@ def analyze_document_for_exam(chunks: List[Dict], max_total: int = None) -> Dict
注意: 注意:
- 不适合的题型数量设为 0 - 不适合的题型数量设为 0
- 所有数量之和不要超过 {min(total_knowledge_points * 2, 20)} - 所有数量之和不要超过 %d
- 必须返回合法 JSON不要有其他内容 - 必须返回合法 JSON不要有其他内容
请直接输出 JSON""" 请直接输出 JSON""" % question_limit
try: try:
response = generator._call_llm(prompt) response = generator._call_llm(prompt)

View File

@@ -219,6 +219,41 @@ def grade_fill_blank(answer: Dict) -> Dict:
student_answers = answer.get('student_answer', []) student_answers = answer.get('student_answer', [])
max_score = answer.get('max_score', 4.0) max_score = answer.get('max_score', 4.0)
if not isinstance(student_answers, list):
logger.warning(
"填空题学生答案格式错误: 期望列表,实际为 %s",
type(student_answers).__name__,
)
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"grading_status": "failed",
"details": {
"error": f"学生答案格式错误,期望列表,实际为 {type(student_answers).__name__}"
},
}
for index, student_answer in enumerate(student_answers):
if not isinstance(student_answer, str):
logger.warning(
"填空题学生答案第 %s 项格式错误: 期望字符串,实际为 %s",
index + 1,
type(student_answer).__name__,
)
return {
"question_id": answer.get('question_id'),
"score": 0,
"max_score": max_score,
"grading_status": "failed",
"details": {
"error": (
f"填空题学生答案第 {index + 1} 项格式错误,期望字符串,"
f"实际为 {type(student_answer).__name__}"
)
},
}
# 归一化答案格式(修复 LLM 生成的扁平数组问题) # 归一化答案格式(修复 LLM 生成的扁平数组问题)
blank_count = question_content.get('data', {}).get('blank_count', 0) blank_count = question_content.get('data', {}).get('blank_count', 0)
correct_answers = _normalize_fill_blank_answer(correct_answers, blank_count) correct_answers = _normalize_fill_blank_answer(correct_answers, blank_count)

View File

@@ -170,8 +170,9 @@ class ExamLocalDB:
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (exam_id, name, description, total_score, len(questions), duration, 'published', created_by)) ''', (exam_id, name, description, total_score, len(questions), duration, 'published', created_by))
# 关联题目 # 关联实际存在的题目,避免无效 ID 触发外键约束。
for order, qid in enumerate(question_ids): valid_question_ids = [q['id'] for q in questions]
for order, qid in enumerate(valid_question_ids):
cursor.execute(''' cursor.execute('''
INSERT INTO exam_questions (exam_id, question_id, question_order) INSERT INTO exam_questions (exam_id, question_id, question_order)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -183,7 +184,7 @@ class ExamLocalDB:
'total_score': total_score, 'total_score': total_score,
'total_count': len(questions), 'total_count': len(questions),
'duration': duration, 'duration': duration,
'question_ids': question_ids 'question_ids': valid_question_ids
} }
def get_exam(self, exam_id: str) -> Optional[Dict]: def get_exam(self, exam_id: str) -> Optional[Dict]:

View File

@@ -664,6 +664,17 @@ class KnowledgeSyncService:
except Exception as e: except Exception as e:
logger.warning(f"递增缓存版本号失败: {e}") logger.warning(f"递增缓存版本号失败: {e}")
# 语义缓存没有知识库版本字段,文档增删改后必须清空;否则回答缓存
# 可能继续返回已过时的来源、引用或图片路径。
try:
from core.semantic_cache import get_semantic_cache
semantic_cache = get_semantic_cache()
if semantic_cache:
semantic_cache.clear()
logger.debug(f"已清空语义缓存(文档变更触发): {kb_name}")
except Exception as e:
logger.warning(f"清空语义缓存失败: {e}")
return True return True
except Exception as e: except Exception as e:

View File

@@ -0,0 +1,273 @@
# -*- coding: utf-8 -*-
"""
RAG 性能分析脚本
分析 RAG 流程各阶段耗时,帮助定位性能瓶颈
"""
import sys
import os
import json
import time
import argparse
from datetime import datetime
from typing import Dict
# 添加项目根目录到路径
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
def call_rag_stream_api(
question: str,
kb_name: str = "public_kb",
base_url: str = "http://localhost:5001",
) -> Dict:
"""
调用 RAG 流式 API 并收集各阶段事件
Returns:
包含各阶段耗时信息的字典
"""
import requests
url = f"{base_url.rstrip('/')}/rag"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer mock-token-admin"
}
data = {
"message": question,
"collections": [kb_name] if kb_name else []
}
events = []
timing = {
'total_duration_ms': 0,
'search_time_ms': 0,
'rerank_time_ms': 0,
'llm_time_ms': 0,
'stages': []
}
try:
start_time = time.time()
response = requests.post(url, json=data, headers=headers, stream=True, timeout=300)
response.raise_for_status()
for line in response.iter_lines():
if line:
line_str = line.decode('utf-8')
if line_str.startswith('data: '):
try:
event = json.loads(line_str[6:])
event['_received_at'] = time.time()
events.append(event)
# 分析 finish 事件中的耗时信息
if event.get('type') == 'finish':
timing['total_duration_ms'] = event.get('duration_ms', 0)
if 'timing' in event:
timing['search_time_ms'] = event['timing'].get('total_search_ms', 0)
timing['rerank_time_ms'] = event['timing'].get('rerank_ms', 0)
timing['rerank_cached'] = event['timing'].get('rerank_cached', False)
except json.JSONDecodeError:
continue
end_time = time.time()
timing['actual_elapsed_ms'] = int((end_time - start_time) * 1000)
# 计算 LLM 生成时间
if timing['total_duration_ms'] > 0 and timing['search_time_ms'] > 0:
timing['llm_time_ms'] = timing['total_duration_ms'] - timing['search_time_ms']
finish_event = next((event for event in events if event.get('type') == 'finish'), None)
error_event = next((event for event in events if event.get('type') == 'error'), None)
return {
'success': finish_event is not None and error_event is None,
'error': error_event.get('message', '') if error_event else (
'' if finish_event else 'SSE 流结束但未收到 finish 事件'
),
'events': events,
'timing': timing,
'answer': finish_event.get('answer', '') if finish_event else '',
}
except requests.exceptions.RequestException as e:
return {
'success': False,
'error': str(e),
'events': events,
'timing': timing
}
def analyze_performance(result: Dict, question: str) -> None:
"""分析并打印性能数据"""
print("\n" + "=" * 80)
print(f"问题: {question}")
print("=" * 80)
if not result['success']:
print(f"[X] 请求失败: {result.get('error', '未知错误')}")
return
timing = result['timing']
events = result['events']
# 打印各阶段事件时间线
print("\n[事件时间线]")
print("-" * 80)
first_event_time = None
for event in events:
event_type = event.get('type', 'unknown')
received_at = event.get('_received_at', 0)
if first_event_time is None:
first_event_time = received_at
relative_time = 0
else:
relative_time = (received_at - first_event_time) * 1000
if event_type == 'finish':
print(f" {relative_time:>8.0f}ms | {event_type:20s} | 总耗时: {event.get('duration_ms', 0)}ms")
elif event_type == 'sources':
sources = event.get('sources', [])
print(f" {relative_time:>8.0f}ms | {event_type:20s} | 找到 {len(sources)} 个来源")
elif event_type == 'chunks_retrieved':
chunks = event.get('data', {}).get('chunks', [])
print(f" {relative_time:>8.0f}ms | {event_type:20s} | 召回 {len(chunks)} 个切片")
elif event_type == 'chunk':
# 流式输出,只显示第一个
if not hasattr(analyze_performance, '_chunk_printed'):
print(f" {relative_time:>8.0f}ms | {event_type:20s} | 开始流式输出...")
analyze_performance._chunk_printed = True
else:
print(f" {relative_time:>8.0f}ms | {event_type:20s}")
if hasattr(analyze_performance, '_chunk_printed'):
delattr(analyze_performance, '_chunk_printed')
# 打印耗时统计
print("\n[耗时统计]")
print("-" * 80)
total = timing['total_duration_ms']
search = timing['search_time_ms']
rerank = timing['rerank_time_ms']
llm = timing['llm_time_ms']
actual = timing.get('actual_elapsed_ms', total)
if total > 0:
print(f" 总耗时 (API报告): {total:>8}ms ({total/1000:.2f}s)")
print(f" 实际耗时 (本地测量): {actual:>8}ms ({actual/1000:.2f}s)")
print()
print(f" 检索阶段: {search:>8}ms ({search/total*100:.1f}%)")
if rerank > 0:
cached_flag = " [缓存]" if timing.get('rerank_cached') else ""
print(f" 重排序阶段: {rerank:>8}ms ({rerank/total*100:.1f}%){cached_flag}")
print(f" LLM生成阶段: {llm:>8}ms ({llm/total*100:.1f}%)")
# 性能诊断
print("\n[性能诊断]")
print("-" * 80)
if total > 10000:
print(" [!] 总耗时超过 10 秒,需要优化")
if search > 3000:
print(f" [!] 检索耗时过长 ({search}ms),可能原因:")
print(" - 向量库数据量过大")
print(" - 未命中查询缓存")
print(" - BM25 索引加载慢")
if rerank > 2000 and not timing.get('rerank_cached'):
print(f" [!] 重排序耗时过长 ({rerank}ms),可能原因:")
print(" - Rerank 模型计算量大")
print(" - 候选切片数量过多")
if llm > 5000:
print(f" [!] LLM生成耗时过长 ({llm}ms),可能原因:")
print(" - 模型生成速度慢")
print(" - 输出 token 数量多")
print(" - 网络延迟")
print(" 建议: 检查 MiMo thinking 是否关闭、上下文长度及 max_tokens")
if total < 5000:
print(" [OK] 性能良好")
else:
print(" [!] 未能获取耗时数据")
def run_performance_tests(
base_url: str = "http://localhost:5001",
kb_name: str = "public_kb",
question: str = None,
):
"""执行性能测试"""
# 测试问题(不同类型)
test_questions = [
{"question": "智启科技成立于哪一年?", "type": "精确匹配"},
{"question": "公司的愿景是什么?", "type": "语义理解"},
{"question": "公司有哪些分公司,分别在哪些城市?", "type": "跨文档关联"},
{"question": "一个入职3年的员工累计病假1个月能拿到多少病假工资", "type": "复杂推理"},
{"question": "P4级工程师的年薪范围是多少", "type": "表格数据"},
]
if question:
test_questions = [{"question": question, "type": "自定义问题"}]
print("=" * 80)
print("RAG Performance Analysis")
print(f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 80)
results = []
for i, test in enumerate(test_questions):
print(f"\n\n[{i+1}/{len(test_questions)}] 类型: {test['type']}")
result = call_rag_stream_api(test['question'], kb_name=kb_name, base_url=base_url)
analyze_performance(result, test['question'])
results.append({
'question': test['question'],
'type': test['type'],
'success': result['success'],
'timing': result['timing']
})
# 汇总统计
print("\n\n" + "=" * 80)
print("[Performance Summary]")
print("=" * 80)
successful = [r for r in results if r['success']]
if successful:
total_times = [r['timing']['total_duration_ms'] for r in successful]
search_times = [r['timing']['search_time_ms'] for r in successful]
llm_times = [r['timing']['llm_time_ms'] for r in successful]
print(f"\n成功请求数: {len(successful)}/{len(results)}")
print(f"\n平均总耗时: {sum(total_times)/len(total_times):.0f}ms")
print(f"平均检索耗时: {sum(search_times)/len(search_times):.0f}ms")
print(f"平均LLM耗时: {sum(llm_times)/len(llm_times):.0f}ms")
print(f"\n最快总耗时: {min(total_times)}ms")
print(f"最慢总耗时: {max(total_times)}ms")
# 保存详细报告
report_dir = os.path.join(PROJECT_ROOT, ".data", "performance")
os.makedirs(report_dir, exist_ok=True)
report_file = os.path.join(
report_dir,
f"performance_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
)
with open(report_file, 'w', encoding='utf-8') as f:
json.dump({
'test_time': datetime.now().isoformat(),
'results': results
}, f, ensure_ascii=False, indent=2)
print(f"\n详细报告已保存: {report_file}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="RAG SSE 链路耗时分析")
parser.add_argument("--base-url", default="http://localhost:5001")
parser.add_argument("--kb", default="public_kb", help="目标知识库名称")
parser.add_argument("--question", help="只测试一个自定义问题")
args = parser.parse_args()
run_performance_tests(args.base_url, args.kb, args.question)

View File

@@ -0,0 +1,82 @@
"""
下载 BGE embedding 和 reranker 模型到本地 models/ 目录
首次配置时运行:
python scripts/download_models.py
"""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS_DIR = os.path.join(PROJECT_ROOT, "models")
EMBEDDING_MODEL_PATH = os.path.join(MODELS_DIR, "bge-base-zh-v1.5")
RERANK_MODEL_PATH = os.path.join(MODELS_DIR, "bge-reranker-base")
os.makedirs(MODELS_DIR, exist_ok=True)
def download_embedding_model():
"""下载 BGE 中文向量模型"""
if os.path.exists(os.path.join(EMBEDDING_MODEL_PATH, "config.json")):
print(f"[跳过] Embedding 模型已存在: {EMBEDDING_MODEL_PATH}")
return True
print(f"[下载] BGE 中文向量模型 (bge-base-zh-v1.5)...")
print(f" 目标路径: {EMBEDDING_MODEL_PATH}")
try:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-base-zh-v1.5")
model.save(EMBEDDING_MODEL_PATH)
print(f"[完成] Embedding 模型下载成功!")
return True
except Exception as e:
print(f"[失败] Embedding 模型下载失败: {e}")
return False
def download_reranker_model():
"""下载 BGE reranker 模型"""
if os.path.exists(os.path.join(RERANK_MODEL_PATH, "config.json")):
print(f"[跳过] Reranker 模型已存在: {RERANK_MODEL_PATH}")
return True
print(f"[下载] BGE Reranker 模型 (bge-reranker-base)...")
print(f" 目标路径: {RERANK_MODEL_PATH}")
try:
from transformers import AutoModelForSequenceClassification, AutoTokenizer
os.makedirs(RERANK_MODEL_PATH, exist_ok=True)
model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-base")
tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-base")
model.save_pretrained(RERANK_MODEL_PATH)
tokenizer.save_pretrained(RERANK_MODEL_PATH)
print(f"[完成] Reranker 模型下载成功!")
return True
except Exception as e:
print(f"[失败] Reranker 模型下载失败: {e}")
print(f" (项目启动时会自动尝试下载,或可稍后手动重试)")
return False
if __name__ == "__main__":
print("=" * 60)
print("RAG 项目模型下载脚本")
print("=" * 60)
ok1 = download_embedding_model()
print()
ok2 = download_reranker_model()
print()
print("=" * 60)
if ok1:
print("Embedding 模型: OK (项目可以启动)")
else:
print("Embedding 模型: FAILED (项目无法启动,请检查网络)")
if ok2:
print("Reranker 模型: OK")
else:
print("Reranker 模型: FAILED (不影响启动,首次使用 rerank 时自动下载)")
print("=" * 60)

101
tests/test_data_db.py Normal file
View File

@@ -0,0 +1,101 @@
"""data.db fresh-clone and transaction regression tests."""
import os
import shutil
import unittest
import uuid
import config
import data.db as db
from exam_pkg.local_db import ExamLocalDB
class DataDbTests(unittest.TestCase):
def setUp(self):
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
test_root = os.path.join(project_root, ".data", "test-runs")
os.makedirs(test_root, exist_ok=True)
self.temp_dir = os.path.join(test_root, f"run-{uuid.uuid4().hex}")
os.makedirs(self.temp_dir)
self.original_paths = db.DB_PATHS
self.original_is_dev = config.IS_DEV
db.DB_PATHS = {
"feedback": os.path.join(self.temp_dir, "prod", "feedback.db"),
"knowledge": os.path.join(self.temp_dir, "prod", "knowledge.db"),
"session": os.path.join(self.temp_dir, "dev", "session.db"),
"exam": os.path.join(self.temp_dir, "dev", "exam.db"),
}
config.IS_DEV = True
db._initialized = False
def tearDown(self):
db._initialized = False
db.DB_PATHS = self.original_paths
config.IS_DEV = self.original_is_dev
test_root = os.path.realpath(os.path.join(os.path.dirname(self.temp_dir)))
target = os.path.realpath(self.temp_dir)
if os.path.commonpath([test_root, target]) != test_root:
raise RuntimeError(f"拒绝清理测试目录之外的路径: {target}")
shutil.rmtree(target, ignore_errors=True)
def _table_names(self, db_name):
with db.get_connection(db_name) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
return {row[0] for row in rows}
def test_fresh_clone_initializes_all_dev_databases(self):
db.init_databases()
for path in db.DB_PATHS.values():
self.assertTrue(os.path.isfile(path), path)
self.assertTrue({"feedbacks", "faqs", "faq_suggestions"} <= self._table_names("feedback"))
self.assertTrue({"document_hashes", "document_versions", "sync_status"} <= self._table_names("knowledge"))
self.assertTrue({"sessions", "messages", "audit_logs"} <= self._table_names("session"))
self.assertTrue({"questions", "exams", "exam_questions"} <= self._table_names("exam"))
with db.get_connection("knowledge") as conn:
columns = {
row[1] for row in conn.execute("PRAGMA table_info(document_versions)").fetchall()
}
self.assertTrue(
{"content_hash", "expiry_date", "deprecated_date", "deprecated_by", "changed_sections"}
<= columns
)
# Initialization must remain safe when several services call it.
db.init_databases()
def test_connection_commits_and_rolls_back(self):
with db.get_connection("session") as conn:
conn.execute("CREATE TABLE tx_test (value TEXT)")
conn.execute("INSERT INTO tx_test VALUES ('committed')")
with self.assertRaisesRegex(RuntimeError, "rollback"):
with db.get_connection("session") as conn:
conn.execute("INSERT INTO tx_test VALUES ('rolled-back')")
raise RuntimeError("rollback")
with db.get_connection("session") as conn:
values = [row[0] for row in conn.execute("SELECT value FROM tx_test").fetchall()]
self.assertEqual(["committed"], values)
def test_exam_creation_ignores_missing_question_ids(self):
exam_db = ExamLocalDB()
question_id = exam_db.add_question(
{"content": "示例题", "answer": "答案", "score": 2},
source_file="example.txt",
source_collection="public",
)
exam = exam_db.create_exam("回归测试", [question_id, "missing-question"])
self.assertEqual(1, exam["total_count"])
self.assertEqual([question_id], exam["question_ids"])
self.assertEqual(1, len(exam_db.get_exam(exam["id"])["questions"]))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,197 @@
"""Regression tests for branch-shared RAG behavior."""
import ast
import json
import os
import pathlib
import unittest
from datetime import datetime
from unittest.mock import patch
from core.cache import RAGCacheManager
from core.intent_analyzer import IntentAnalyzer
from exam_pkg.generator import QuestionGenerator, analyze_document_for_exam
from exam_pkg.grader import grade_fill_blank
from knowledge.sync import ChangeType, DocumentChange, KnowledgeSyncService
class CacheInvalidationTests(unittest.TestCase):
def test_kb_version_change_clears_rerank_scores(self):
cache = RAGCacheManager()
cache.set_rerank_scores("问题", ["doc-1"], [0.91])
self.assertEqual({"doc-1": 0.91}, cache.get_rerank_scores("问题", ["doc-1"]))
cache.increment_kb_version("public")
self.assertIsNone(cache.get_rerank_scores("问题", ["doc-1"]))
class IntentCacheTests(unittest.TestCase):
def test_unknown_semantic_cache_type_is_not_used_as_intent(self):
class FakeEmbeddingModel:
def encode(self, _text):
return [1.0, 0.0]
class FakeCache:
def __init__(self):
self.saved = None
def get(self, _embedding):
return {
"cache_type": "future_cache_type",
"rewritten_query": "错误的缓存结果",
}
def set(self, _embedding, value):
self.saved = value
def get_stats(self):
return {}
analyzer = IntentAnalyzer(model="mimo-v2.5")
analyzer._embedding_model = FakeEmbeddingModel()
analyzer._cache = FakeCache()
analyzer._client = object()
llm_result = json.dumps(
{
"rewritten_query": "来自 LLM 的新结果",
"use_context": False,
"need_retrieval": True,
"reason": "新问题",
"sub_queries": ["来自 LLM 的新结果"],
"intent": "factual",
},
ensure_ascii=False,
)
with patch("core.llm_utils.call_llm", return_value=llm_result):
result = analyzer.analyze("当前问题", [])
self.assertEqual("来自 LLM 的新结果", result.rewritten_query)
self.assertEqual("intent_analysis", analyzer._cache.saved["cache_type"])
class ExamRegressionTests(unittest.TestCase):
def test_analysis_prompt_respects_requested_max_total(self):
captured = {}
def fake_call(_self, prompt):
captured["prompt"] = prompt
return json.dumps(
{
"total_knowledge_points": 2,
"suitable_types": ["single_choice"],
"question_types": {
"single_choice": 3,
"multiple_choice": 0,
"true_false": 0,
"fill_blank": 0,
"subjective": 0,
},
"reason": "测试",
},
ensure_ascii=False,
)
chunks = [{"content": "知识点" * 80, "section": "第一章"}]
with patch.object(QuestionGenerator, "_call_llm", fake_call):
result = analyze_document_for_exam(chunks, max_total=3)
self.assertIn("所有数量之和不要超过 3", captured["prompt"])
self.assertLessEqual(sum(result["question_types"].values()), 3)
def test_fill_blank_rejects_non_list_student_answer(self):
result = grade_fill_blank(
{
"question_id": "q1",
"content": {"answer": [["正确答案"]], "data": {"blank_count": 1}},
"student_answer": "正确答案",
"max_score": 2,
}
)
self.assertEqual("failed", result["grading_status"])
self.assertEqual(0, result["score"])
def test_fill_blank_rejects_non_string_items(self):
result = grade_fill_blank(
{
"question_id": "q1",
"content": {"answer": [["正确答案"]], "data": {"blank_count": 1}},
"student_answer": [{"text": "正确答案"}],
"max_score": 2,
}
)
self.assertEqual("failed", result["grading_status"])
self.assertIn("第 1 项", result["details"]["error"])
class SyncInvalidationTests(unittest.TestCase):
def test_document_delete_clears_semantic_cache(self):
class FakeDb:
def delete_document_hash(self, _document_id):
return None
class FakeKbManager:
def delete_document(self, _kb_name, _document_name):
return 1
class FakeSemanticCache:
def __init__(self):
self.cleared = False
def clear(self):
self.cleared = True
service = object.__new__(KnowledgeSyncService)
service.documents_path = "unused"
service.db = FakeDb()
semantic_cache = FakeSemanticCache()
change = DocumentChange(
document_id="public/example.pdf",
document_name="example.pdf",
change_type=ChangeType.DELETED,
old_hash="old",
new_hash=None,
change_time=datetime.now(),
)
with (
patch("knowledge.manager.get_kb_manager", return_value=FakeKbManager()),
patch("knowledge.sync.CACHE_AVAILABLE", False),
patch("core.semantic_cache.get_semantic_cache", return_value=semantic_cache),
):
self.assertTrue(service.process_change(change))
self.assertTrue(semantic_cache.cleared)
class ConfigTemplateTests(unittest.TestCase):
def test_config_example_defines_all_direct_imports(self):
root = pathlib.Path(__file__).resolve().parents[1]
template_tree = ast.parse((root / "config.example.py").read_text(encoding="utf-8"))
defined = {
node.name
for node in template_tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
for node in template_tree.body:
if isinstance(node, ast.Assign):
defined.update(target.id for target in node.targets if isinstance(target, ast.Name))
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
defined.add(node.target.id)
imported = set()
excluded = {"venv", ".git", ".data", ".recovery", "__pycache__"}
for path in root.rglob("*.py"):
if any(part in excluded for part in path.parts):
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module == "config":
imported.update(alias.name for alias in node.names)
self.assertEqual([], sorted(imported - defined))
if __name__ == "__main__":
unittest.main()

View File

@@ -9,12 +9,30 @@ Phase 3 验证测试:重复上传旧切片残留修复
import os import os
import sys import sys
import tempfile
import shutil import shutil
import uuid
from pathlib import Path from pathlib import Path
# 添加项目根目录到 Python 路径 # 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEST_TEMP_ROOT = os.path.join(PROJECT_ROOT, ".data", "test-runs")
sys.path.insert(0, PROJECT_ROOT)
def _make_test_dir():
"""在工作区内创建测试目录,兼容 Codex 的 Windows 文件权限。"""
os.makedirs(TEST_TEMP_ROOT, exist_ok=True)
path = os.path.join(TEST_TEMP_ROOT, f"upload-{uuid.uuid4().hex}")
os.makedirs(path)
return path
def _cleanup_test_dir(path):
root = os.path.realpath(TEST_TEMP_ROOT)
target = os.path.realpath(path)
if os.path.commonpath([root, target]) != root:
raise RuntimeError(f"拒绝清理测试目录之外的路径: {target}")
shutil.rmtree(target, ignore_errors=True)
def test_add_file_to_kb_dedup(): def test_add_file_to_kb_dedup():
@@ -143,7 +161,7 @@ def test_upload_document_overwrite():
print("\n=== 测试 2upload_document 同名文件覆盖 ===") print("\n=== 测试 2upload_document 同名文件覆盖 ===")
# 创建临时目录 # 创建临时目录
temp_dir = tempfile.mkdtemp() temp_dir = _make_test_dir()
try: try:
target_dir = os.path.join(temp_dir, 'public_kb') target_dir = os.path.join(temp_dir, 'public_kb')
os.makedirs(target_dir, exist_ok=True) os.makedirs(target_dir, exist_ok=True)
@@ -184,14 +202,14 @@ def test_upload_document_overwrite():
print(f" [PASS] replaced={replaced}") print(f" [PASS] replaced={replaced}")
finally: finally:
shutil.rmtree(temp_dir) _cleanup_test_dir(temp_dir)
def test_batch_upload_overwrite(): def test_batch_upload_overwrite():
"""测试批量上传中的同名文件覆盖逻辑""" """测试批量上传中的同名文件覆盖逻辑"""
print("\n=== 测试 3批量上传同名文件覆盖 ===") print("\n=== 测试 3批量上传同名文件覆盖 ===")
temp_dir = tempfile.mkdtemp() temp_dir = _make_test_dir()
try: try:
target_dir = os.path.join(temp_dir, 'public_kb') target_dir = os.path.join(temp_dir, 'public_kb')
os.makedirs(target_dir, exist_ok=True) os.makedirs(target_dir, exist_ok=True)
@@ -240,14 +258,14 @@ def test_batch_upload_overwrite():
print(f" [PASS] 文件内容已更新") print(f" [PASS] 文件内容已更新")
finally: finally:
shutil.rmtree(temp_dir) _cleanup_test_dir(temp_dir)
def test_docstore_cleanup(): def test_docstore_cleanup():
"""测试 DocStore 文件清理逻辑""" """测试 DocStore 文件清理逻辑"""
print("\n=== 测试 4DocStore 文件清理 ===") print("\n=== 测试 4DocStore 文件清理 ===")
temp_dir = tempfile.mkdtemp() temp_dir = _make_test_dir()
try: try:
docstore_dir = Path(temp_dir) / 'docstore' docstore_dir = Path(temp_dir) / 'docstore'
docstore_dir.mkdir() docstore_dir.mkdir()
@@ -289,7 +307,7 @@ def test_docstore_cleanup():
print(f" [PASS] 剩余 {len(remaining)} 个无关文件未被清理") print(f" [PASS] 剩余 {len(remaining)} 个无关文件未被清理")
finally: finally:
shutil.rmtree(temp_dir) _cleanup_test_dir(temp_dir)
def test_sync_hash_cleanup(): def test_sync_hash_cleanup():