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

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

14
services/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
"""
业务服务模块
包含:
- session: 会话管理(开发环境)
- feedback: 问答质量闭环反馈、FAQ、质量报告
- outline: 纲要生成与关联推荐
"""
from services.session import SessionManager
__all__ = [
'SessionManager',
]

1314
services/feedback.py Normal file

File diff suppressed because it is too large Load Diff

986
services/outline.py Normal file
View File

@@ -0,0 +1,986 @@
"""
纲要生成与关联推荐服务
功能:
1. GKPT-MIND-020 自动化纲要生成
- AI提取制度文件章节结构
- 生成思维导图数据
- 支持多种格式导出
2. GKPT-READ-005 关联推荐
- 基于向量相似度推荐相关文档
- 支持标签匹配
- 综合排序
"""
import json
import os
import hashlib
import logging
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from dataclasses import dataclass, asdict, field
from data.db import get_connection, init_databases
from core.llm_utils import call_llm, parse_json_from_response
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# ==================== 数据类定义 ====================
@dataclass
class OutlineNode:
"""纲要节点"""
id: str = ""
title: str = ""
summary: str = ""
level: int = 1
order: int = 1
page: int = 0
children: List['OutlineNode'] = field(default_factory=list)
def to_dict(self) -> Dict:
"""转换为字典"""
return {
"id": self.id,
"title": self.title,
"summary": self.summary,
"level": self.level,
"order": self.order,
"page": self.page,
"children": [child.to_dict() for child in self.children]
}
@classmethod
def from_dict(cls, data: Dict) -> 'OutlineNode':
"""从字典创建"""
node = cls(
id=data.get("id", ""),
title=data.get("title", ""),
summary=data.get("summary", ""),
level=data.get("level", 1),
order=data.get("order", 1),
page=data.get("page", 0)
)
for child_data in data.get("children", []):
node.children.append(cls.from_dict(child_data))
return node
@dataclass
class DocumentOutline:
"""文档纲要"""
document_id: str = ""
document_name: str = ""
total_pages: int = 0
content_hash: str = ""
generated_at: str = ""
outline: List[OutlineNode] = field(default_factory=list)
def to_dict(self) -> Dict:
"""转换为字典"""
return {
"document_id": self.document_id,
"document_name": self.document_name,
"total_pages": self.total_pages,
"content_hash": self.content_hash,
"generated_at": self.generated_at,
"outline": [node.to_dict() for node in self.outline]
}
@classmethod
def from_dict(cls, data: Dict) -> 'DocumentOutline':
"""从字典创建"""
return cls(
document_id=data.get("document_id", ""),
document_name=data.get("document_name", ""),
total_pages=data.get("total_pages", 0),
content_hash=data.get("content_hash", ""),
generated_at=data.get("generated_at", ""),
outline=[OutlineNode.from_dict(n) for n in data.get("outline", [])]
)
@dataclass
class Recommendation:
"""推荐结果"""
document_id: str = ""
document_name: str = ""
summary: str = ""
similarity: float = 0.0
tag_score: float = 0.0
final_score: float = 0.0
tags: List[str] = field(default_factory=list)
reason: str = "" # 推荐理由
def to_dict(self) -> Dict:
return asdict(self)
# ==================== 数据库管理 ====================
class OutlineDB:
"""纲要缓存数据库"""
def __init__(self):
init_databases()
self._init_db()
def _init_db(self):
"""初始化数据库表"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
# 纲要缓存表
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 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 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 INDEX IF NOT EXISTS idx_outline_doc ON outline_cache(document_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_vector_doc ON document_vectors(document_id)")
# ==================== 纲要缓存 ====================
def save_outline(self, outline: DocumentOutline) -> int:
"""保存纲要"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO outline_cache
(document_id, document_name, total_pages, content_hash, outline_json, generated_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (
outline.document_id,
outline.document_name,
outline.total_pages,
outline.content_hash,
json.dumps(outline.to_dict(), ensure_ascii=False),
outline.generated_at
))
return cursor.lastrowid
def get_outline(self, document_id: str) -> Optional[DocumentOutline]:
"""获取纲要"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT document_id, document_name, total_pages, content_hash,
outline_json, generated_at
FROM outline_cache WHERE document_id = ?
""", (document_id,))
row = cursor.fetchone()
if not row:
return None
outline_data = json.loads(row[4])
return DocumentOutline.from_dict(outline_data)
def delete_outline(self, document_id: str) -> bool:
"""删除纲要缓存"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM outline_cache WHERE document_id = ?", (document_id,))
return cursor.rowcount > 0
def list_outlines(self, limit: int = 50) -> List[Dict]:
"""获取纲要列表"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT document_id, document_name, total_pages, generated_at
FROM outline_cache ORDER BY generated_at DESC LIMIT ?
""", (limit,))
rows = cursor.fetchall()
return [
{
"document_id": row[0],
"document_name": row[1],
"total_pages": row[2],
"generated_at": row[3]
}
for row in rows
]
# ==================== 文档向量 ====================
def save_document_vector(self, document_id: str, document_name: str,
vector: List[float], tags: List[str] = None) -> int:
"""保存文档向量"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
vector_hash = hashlib.md5(str(vector[:10]).encode()).hexdigest()[:8]
cursor.execute("""
INSERT OR REPLACE INTO document_vectors
(document_id, document_name, vector_hash, vector_json, tags_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (
document_id,
document_name,
vector_hash,
json.dumps(vector),
json.dumps(tags or [], ensure_ascii=False),
datetime.now().isoformat()
))
return cursor.lastrowid
def get_document_vector(self, document_id: str) -> Optional[Dict]:
"""获取文档向量"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT document_id, document_name, vector_json, tags_json
FROM document_vectors WHERE document_id = ?
""", (document_id,))
row = cursor.fetchone()
if not row:
return None
return {
"document_id": row[0],
"document_name": row[1],
"vector": json.loads(row[2]),
"tags": json.loads(row[3]) if row[3] else []
}
def get_all_document_vectors(self) -> List[Dict]:
"""获取所有文档向量"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT document_id, document_name, vector_json, tags_json
FROM document_vectors
""")
rows = cursor.fetchall()
return [
{
"document_id": row[0],
"document_name": row[1],
"vector": json.loads(row[2]),
"tags": json.loads(row[3]) if row[3] else []
}
for row in rows
]
# ==================== 推荐缓存 ====================
def save_recommendations(self, document_id: str, recommendations: List[Recommendation]) -> int:
"""保存推荐结果"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
# 先删除旧缓存
cursor.execute("DELETE FROM recommendation_cache WHERE document_id = ?", (document_id,))
cursor.execute("""
INSERT INTO recommendation_cache (document_id, recommendations_json, generated_at)
VALUES (?, ?, ?)
""", (
document_id,
json.dumps([r.to_dict() for r in recommendations], ensure_ascii=False),
datetime.now().isoformat()
))
return cursor.lastrowid
def get_recommendations(self, document_id: str) -> Optional[List[Recommendation]]:
"""获取推荐结果"""
with get_connection("knowledge") as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT recommendations_json FROM recommendation_cache WHERE document_id = ?
""", (document_id,))
row = cursor.fetchone()
if not row:
return None
return [Recommendation(**r) for r in json.loads(row[0])]
# ==================== 纲要生成服务 ====================
class OutlineGenerator:
"""纲要生成服务"""
def __init__(self, db: OutlineDB, documents_path: str = "./documents"):
self.db = db
self.documents_path = documents_path
self.llm_client = None
self.embedding_model = None
self._init_llm()
def _init_llm(self):
"""初始化LLM客户端"""
try:
from config import API_KEY, BASE_URL, MODEL
from openai import OpenAI
self.llm_client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
self.model = MODEL
logger.info("LLM客户端初始化成功")
except ImportError:
logger.warning("未找到LLM配置纲要生成功能受限")
self.llm_client = None
def generate_outline(self, document_id: str, force: bool = False) -> DocumentOutline:
"""
生成文档纲要
Args:
document_id: 文档ID相对路径如 public/差旅管理办法.txt
force: 是否强制重新生成
Returns:
DocumentOutline
"""
# 1. 检查缓存
if not force:
cached = self.db.get_outline(document_id)
if cached:
# 检查内容是否变化
current_hash = self._get_document_hash(document_id)
if current_hash == cached.content_hash:
logger.info(f"使用缓存的纲要: {document_id}")
return cached
# 2. 获取文档内容
document_content = self._read_document(document_id)
if not document_content:
raise ValueError(f"文档不存在或无法读取: {document_id}")
document_name = os.path.basename(document_id)
# 3. 使用LLM提取结构
outline_data = self._extract_structure(document_content, document_name)
# 4. 构建纲要对象
outline = DocumentOutline(
document_id=document_id,
document_name=document_name,
total_pages=0, # 可以后续计算
content_hash=self._get_document_hash(document_id),
generated_at=datetime.now().isoformat(),
outline=[OutlineNode.from_dict(n) for n in outline_data.get("children", [])]
)
# 5. 保存缓存
self.db.save_outline(outline)
logger.info(f"纲要生成完成: {document_id}")
return outline
def _read_document(self, document_id: str) -> Optional[str]:
"""读取文档内容"""
# 处理不同的文档格式
file_path = os.path.join(self.documents_path, document_id)
if not os.path.exists(file_path):
logger.error(f"文档不存在: {file_path}")
return None
ext = os.path.splitext(file_path)[1].lower()
try:
if ext == '.txt':
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
elif ext == '.pdf':
# 使用 pdfplumber 提取文本
try:
import pdfplumber
text_parts = []
with pdfplumber.open(file_path) as pdf:
for page in pdf.pages:
text = page.extract_text()
if text:
text_parts.append(text)
return '\n'.join(text_parts)
except ImportError:
logger.warning("pdfplumber未安装无法读取PDF")
return None
elif ext in ['.docx', '.doc']:
# 使用 python-docx 提取文本
try:
from docx import Document
doc = Document(file_path)
return '\n'.join([p.text for p in doc.paragraphs if p.text.strip()])
except ImportError:
logger.warning("python-docx未安装无法读取Word文档")
return None
elif ext == '.xlsx':
# 使用 openpyxl 提取文本
try:
from openpyxl import load_workbook
wb = load_workbook(file_path)
text_parts = []
for sheet in wb.worksheets:
for row in sheet.iter_rows(values_only=True):
text_parts.extend([str(cell) for cell in row if cell])
return '\n'.join(text_parts)
except ImportError:
logger.warning("openpyxl未安装无法读取Excel")
return None
else:
# 尝试作为文本读取
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
except Exception as e:
logger.error(f"读取文档失败: {e}")
return None
def _get_document_hash(self, document_id: str) -> str:
"""计算文档哈希"""
file_path = os.path.join(self.documents_path, document_id)
if not os.path.exists(file_path):
return ""
with open(file_path, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
def _extract_structure(self, content: str, document_name: str) -> Dict:
"""使用LLM提取文档结构"""
if not self.llm_client:
# 返回基本结构
return {
"title": document_name,
"summary": "无法生成摘要LLM未配置",
"children": []
}
# 限制内容长度
max_length = 8000
if len(content) > max_length:
content = content[:max_length] + "\n...(内容已截断)"
prompt = f"""请分析以下制度文档,提取章节结构和核心要点。
文档名称:{document_name}
文档内容:
{content}
请按以下格式返回JSON
{{
"title": "文档标题",
"summary": "文档概述50字以内",
"children": [
{{
"id": "1",
"title": "第一章 一级标题",
"summary": "本章核心要点30字以内",
"level": 1,
"order": 1,
"children": [
{{
"id": "1.1",
"title": "1.1 二级标题",
"summary": "核心要点",
"level": 2,
"order": 1,
"children": []
}}
]
}}
]
}}
要求:
1. 识别文档的一级、二级、三级标题(如有)
2. 每个章节提取核心要点不超过30字
3. 保持层级关系最多3层
4. 只返回JSON不要有其他内容"""
try:
result_text = call_llm(
self.llm_client,
prompt=prompt,
model=self.model,
temperature=0.3,
max_tokens=3000
)
if not result_text:
logger.error("LLM提取结构返回空")
return {
"title": document_name,
"summary": "生成失败: LLM返回空",
"children": []
}
# 清理可能的markdown标记
if result_text.startswith("```json"):
result_text = result_text[7:]
if result_text.startswith("```"):
result_text = result_text[3:]
if result_text.endswith("```"):
result_text = result_text[:-3]
result_text = result_text.strip()
return json.loads(result_text)
except Exception as e:
logger.error(f"LLM提取结构失败: {e}")
return {
"title": document_name,
"summary": f"生成失败: {str(e)}",
"children": []
}
def export_outline(self, outline: DocumentOutline, format: str = "json") -> str:
"""
导出纲要
Args:
outline: 纲要对象
format: 导出格式 (json/markdown/markmap)
Returns:
导出内容
"""
if format == "json":
return json.dumps(outline.to_dict(), ensure_ascii=False, indent=2)
elif format == "markdown":
return self._export_markdown(outline)
elif format == "markmap":
# markmap 格式(可渲染为思维导图的 Markdown
return self._export_markmap(outline)
else:
raise ValueError(f"不支持的导出格式: {format}")
def _export_markdown(self, outline: DocumentOutline, node: OutlineNode = None, level: int = 0) -> str:
"""导出为 Markdown 格式"""
lines = []
if node is None:
# 根级别
lines.append(f"# {outline.document_name}\n")
lines.append(f"> 生成时间: {outline.generated_at}\n")
for child in outline.outline:
lines.append(self._export_markdown(outline, child, 1))
else:
# 节点级别
prefix = "#" * (level + 1)
lines.append(f"{prefix} {node.title}\n")
if node.summary:
lines.append(f"{node.summary}\n")
for child in node.children:
lines.append(self._export_markdown(outline, child, level + 1))
return "\n".join(lines)
def _export_markmap(self, outline: DocumentOutline) -> str:
"""导出为 markmap 格式(思维导图 Markdown"""
lines = [f"# {outline.document_name}"]
def render_node(node: OutlineNode, level: int):
indent = " " * level
lines.append(f"{indent}- {node.title}")
if node.summary:
lines.append(f"{indent} - *{node.summary}*")
for child in node.children:
render_node(child, level + 1)
for node in outline.outline:
render_node(node, 1)
return "\n".join(lines)
def batch_generate(self, document_ids: List[str], force: bool = False) -> Dict[str, DocumentOutline]:
"""
批量生成纲要
Args:
document_ids: 文档ID列表
force: 是否强制重新生成
Returns:
{document_id: DocumentOutline}
"""
results = {}
for doc_id in document_ids:
try:
results[doc_id] = self.generate_outline(doc_id, force)
except Exception as e:
logger.error(f"生成纲要失败 {doc_id}: {e}")
results[doc_id] = None
return results
# ==================== 关联推荐服务 ====================
class RecommendationService:
"""关联推荐服务"""
def __init__(self, db: OutlineDB, documents_path: str = "./documents",
chroma_collection=None, embedding_model=None):
self.db = db
self.documents_path = documents_path
self.chroma_collection = chroma_collection
self.embedding_model = embedding_model
def get_recommendations(self, document_id: str, top_k: int = 5,
use_cache: bool = True) -> List[Recommendation]:
"""
获取关联推荐
Args:
document_id: 当前文档ID
top_k: 返回数量
use_cache: 是否使用缓存
Returns:
推荐列表
"""
# 1. 检查缓存
if use_cache:
cached = self.db.get_recommendations(document_id)
if cached:
logger.info(f"使用缓存的推荐: {document_id}")
return cached[:top_k]
# 2. 获取当前文档向量
current_vector = self._get_or_compute_vector(document_id)
if current_vector is None:
logger.warning(f"无法获取文档向量: {document_id}")
return []
current_doc = self.db.get_document_vector(document_id)
current_tags = current_doc.get("tags", []) if current_doc else []
# 3. 检索相似文档
similar_docs = self._search_similar(current_vector, top_k * 3, exclude_id=document_id)
# 4. 计算综合得分
recommendations = []
for doc in similar_docs:
# 标签匹配得分
doc_tags = doc.get("tags", [])
tag_overlap = len(set(current_tags) & set(doc_tags))
tag_score = min(tag_overlap * 0.15, 0.3) # 最高0.3
# 综合得分
similarity = doc.get("similarity", 0)
final_score = similarity * 0.7 + tag_score
# 推荐理由
reasons = []
if similarity > 0.8:
reasons.append("内容高度相似")
elif similarity > 0.6:
reasons.append("内容相关")
if tag_overlap > 0:
reasons.append(f"包含{tag_overlap}个相同标签")
recommendation = Recommendation(
document_id=doc.get("document_id", ""),
document_name=doc.get("document_name", ""),
summary=doc.get("summary", "")[:100] if doc.get("summary") else "",
similarity=round(similarity, 3),
tag_score=round(tag_score, 3),
final_score=round(final_score, 3),
tags=doc_tags[:5],
reason="".join(reasons) if reasons else "相关推荐"
)
recommendations.append(recommendation)
# 5. 排序并截取
recommendations.sort(key=lambda x: x.final_score, reverse=True)
results = recommendations[:top_k]
# 6. 缓存结果
if results:
self.db.save_recommendations(document_id, results)
return results
def _get_or_compute_vector(self, document_id: str) -> Optional[List[float]]:
"""获取或计算文档向量"""
# 检查缓存
cached = self.db.get_document_vector(document_id)
if cached and cached.get("vector"):
return cached["vector"]
# 计算向量
if not self.embedding_model:
logger.warning("嵌入模型未初始化,无法计算向量")
return None
# 读取文档内容
file_path = os.path.join(self.documents_path, document_id)
if not os.path.exists(file_path):
return None
try:
# 使用 _read_document 方法读取文档(支持多种格式)
content = self._read_document(file_path)
if not content:
logger.warning(f"无法读取文档内容: {document_id}")
return None
# 计算向量
vector = self.embedding_model.encode(content[:5000]) # 限制长度
# 缓存
doc_name = os.path.basename(document_id)
self.db.save_document_vector(document_id, doc_name, vector.tolist())
return vector.tolist()
except Exception as e:
logger.error(f"计算文档向量失败: {e}")
return None
def _search_similar(self, query_vector: List[float], top_k: int,
exclude_id: str = None) -> List[Dict]:
"""搜索相似文档"""
if not self.chroma_collection:
# 使用数据库缓存
all_docs = self.db.get_all_document_vectors()
results = []
import numpy as np
query_vec = np.array(query_vector)
for doc in all_docs:
if exclude_id and doc["document_id"] == exclude_id:
continue
doc_vec = np.array(doc["vector"])
# 余弦相似度
similarity = np.dot(query_vec, doc_vec) / (
np.linalg.norm(query_vec) * np.linalg.norm(doc_vec)
)
results.append({
"document_id": doc["document_id"],
"document_name": doc["document_name"],
"similarity": float(similarity),
"tags": doc.get("tags", [])
})
results.sort(key=lambda x: x["similarity"], reverse=True)
return results[:top_k]
# 使用 ChromaDB
try:
results = self.chroma_collection.query(
query_embeddings=[query_vector],
n_results=top_k + 1 # 多取一个,排除自己
)
docs = []
for i, doc_id in enumerate(results.get("ids", [[]])[0]):
if exclude_id and doc_id == exclude_id:
continue
metadata = results.get("metadatas", [[]])[0][i] if results.get("metadatas") else {}
distance = results.get("distances", [[]])[0][i] if results.get("distances") else 0
# 转换距离为相似度
similarity = 1 - distance if distance < 1 else 0
docs.append({
"document_id": doc_id,
"document_name": metadata.get("source", doc_id),
"similarity": similarity,
"tags": metadata.get("tags", "").split(",") if metadata.get("tags") else []
})
return docs[:top_k]
except Exception as e:
logger.error(f"相似文档检索失败: {e}")
return []
def compute_all_vectors(self) -> int:
"""计算所有文档的向量"""
if not self.embedding_model:
logger.warning("嵌入模型未初始化")
return 0
count = 0
for root, dirs, files in os.walk(self.documents_path):
for file in files:
if file.endswith(('.txt', '.pdf', '.docx', '.md')):
document_id = os.path.relpath(
os.path.join(root, file),
self.documents_path
).replace("\\", "/")
try:
self._get_or_compute_vector(document_id)
count += 1
except Exception as e:
logger.error(f"计算向量失败 {document_id}: {e}")
logger.info(f"计算了 {count} 个文档的向量")
return count
# ==================== 便捷函数 ====================
def create_services(documents_path: str = "./documents",
chroma_collection=None,
embedding_model=None) -> Tuple[OutlineDB, OutlineGenerator, RecommendationService]:
"""
创建服务实例
Args:
documents_path: 文档目录
chroma_collection: ChromaDB集合
embedding_model: 嵌入模型
Returns:
(数据库实例, 纲要生成服务, 推荐服务)
"""
db = OutlineDB()
outline_generator = OutlineGenerator(db, documents_path)
recommendation_service = RecommendationService(
db, documents_path, chroma_collection, embedding_model
)
return db, outline_generator, recommendation_service
# ==================== 使用示例 ====================
if __name__ == "__main__":
import sys
# 设置编码
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("纲要生成与关联推荐服务测试")
print("=" * 60)
# 创建服务
db, outline_svc, rec_svc = create_services(
documents_path="./documents"
)
# 测试纲要生成
print("\n[1] 测试纲要生成...")
# 查找一个测试文档
documents_path = "./documents"
test_doc = None
for root, dirs, files in os.walk(documents_path):
for file in files:
if file.endswith('.txt'):
test_doc = os.path.relpath(
os.path.join(root, file),
documents_path
).replace("\\", "/")
break
if test_doc:
break
if test_doc:
print(f" 测试文档: {test_doc}")
try:
outline = outline_svc.generate_outline(test_doc)
print(f" 标题: {outline.document_name}")
print(f" 章节数: {len(outline.outline)}")
for node in outline.outline[:3]:
print(f" - {node.title}: {node.summary[:30]}...")
# 测试导出
print("\n[2] 测试导出...")
md_content = outline_svc.export_outline(outline, "markdown")
print(f" Markdown 导出长度: {len(md_content)} 字符")
markmap_content = outline_svc.export_outline(outline, "markmap")
print(f" Markmap 导出长度: {len(markmap_content)} 字符")
except Exception as e:
print(f" 纲要生成失败: {e}")
else:
print(" 未找到测试文档")
# 测试推荐服务
print("\n[3] 测试关联推荐...")
if test_doc:
try:
recommendations = rec_svc.get_recommendations(test_doc, top_k=3)
print(f" 推荐数量: {len(recommendations)}")
for rec in recommendations:
print(f" - {rec.document_name} (相似度: {rec.similarity}, 理由: {rec.reason})")
except Exception as e:
print(f" 推荐失败: {e}")
# 列出纲要缓存
print("\n[4] 测试纲要缓存...")
outlines = db.list_outlines()
print(f" 缓存数量: {len(outlines)}")
print("\n" + "=" * 60)
print("测试完成")
print("=" * 60)

437
services/session.py Normal file
View File

@@ -0,0 +1,437 @@
"""
会话管理器 - 支持多用户对话历史
功能:
1. 多用户会话隔离
2. 对话历史持久化SQLite
3. 历史压缩(避免上下文过长)
4. 会话过期清理
使用方式:
from services.session import SessionManager
sm = SessionManager()
session_id = sm.create_session("user_123")
# 添加对话
sm.add_message(session_id, "user", "出差补助标准是什么?")
sm.add_message(session_id, "assistant", "根据规定...")
# 获取历史
history = sm.get_history(session_id)
# 生成带历史的提示词
context = sm.build_context(session_id, "那请假呢?")
"""
import json
import uuid
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from data.db import get_connection
class SessionManager:
"""会话管理器"""
def __init__(self, session_expire_hours: int = 24):
"""
初始化会话管理器
Args:
session_expire_hours: 会话过期时间(小时)
"""
self.session_expire_hours = session_expire_hours
self._init_db()
def _init_db(self):
"""初始化数据库表"""
from data.db import init_databases
init_databases()
def create_session(self, user_id: str, metadata: dict = None) -> str:
"""
创建新会话
Args:
user_id: 用户ID
metadata: 可选的元数据如用户名、IP等
Returns:
session_id: 会话ID
"""
session_id = str(uuid.uuid4())
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO sessions (session_id, user_id, metadata)
VALUES (?, ?, ?)
''', (session_id, user_id, json.dumps(metadata or {})))
return session_id
def get_or_create_session(self, user_id: str, session_id: str = None) -> str:
"""
获取或创建会话
Args:
user_id: 用户ID
session_id: 可选的会话ID如果提供则验证归属
Returns:
session_id: 有效会话ID
"""
if session_id:
# 验证会话是否存在且属于该用户
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT session_id FROM sessions
WHERE session_id = ? AND user_id = ?
''', (session_id, user_id))
result = cursor.fetchone()
if result:
# 更新最后活跃时间
self._update_last_active(session_id)
return session_id
# 创建新会话
return self.create_session(user_id)
def _update_last_active(self, session_id: str):
"""更新会话最后活跃时间"""
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE sessions
SET last_active = CURRENT_TIMESTAMP
WHERE session_id = ?
''', (session_id,))
def add_message(self, session_id: str, role: str, content: str, metadata: dict = None):
"""
添加消息到会话历史
Args:
session_id: 会话ID
role: 角色 (user/assistant)
content: 消息内容
metadata: 可选的扩展数据如sources, images, is_rag
"""
import json
meta_str = json.dumps(metadata) if metadata else None
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO messages (session_id, role, content, metadata)
VALUES (?, ?, ?, ?)
''', (session_id, role, content, meta_str))
# 更新会话活跃时间
cursor.execute('''
UPDATE sessions
SET last_active = CURRENT_TIMESTAMP
WHERE session_id = ?
''', (session_id,))
def get_history(self, session_id: str, limit: int = 20) -> List[Dict]:
"""
获取会话历史
Args:
session_id: 会话ID
limit: 最大消息数
Returns:
[{"id": 1, "role": "user/assistant", "content": "...", "metadata": {...}}, ...]
"""
import json
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, role, content, metadata, created_at
FROM messages
WHERE session_id = ?
ORDER BY created_at DESC
LIMIT ?
''', (session_id, limit))
rows = cursor.fetchall()
# 按时间正序排列(旧的在前)
history = []
for row in reversed(rows):
meta_str = row[3]
meta = {}
if meta_str:
try:
meta = json.loads(meta_str)
except (json.JSONDecodeError, TypeError):
pass
history.append({
"id": row[0],
"role": row[1],
"content": row[2],
"metadata": meta,
"created_at": row[4]
})
return history
def get_history_text(self, session_id: str, limit: int = 10) -> str:
"""
获取历史文本格式用于Prompt
Args:
session_id: 会话ID
limit: 最大轮次(一问一答为一轮)
Returns:
格式化的历史文本
"""
history = self.get_history(session_id, limit=limit * 2)
if not history:
return ""
lines = []
for msg in history:
role_name = "用户" if msg["role"] == "user" else "助手"
lines.append(f"{role_name}{msg['content']}")
return "\n".join(lines)
def build_context(self, session_id: str, current_query: str,
max_history_tokens: int = 1500) -> str:
"""
构建带历史的上下文(智能压缩)
Args:
session_id: 会话ID
current_query: 当前问题
max_history_tokens: 历史最大token数估算
Returns:
包含历史的上下文文本
"""
history = self.get_history(session_id, limit=20)
if not history:
return current_query
# 构建历史摘要
history_text = self._compress_history(history, max_history_tokens)
context = f"""【对话历史】
{history_text}
【当前问题】
{current_query}"""
return context
def _compress_history(self, history: List[Dict], max_tokens: int) -> str:
"""
压缩历史(避免上下文过长)
策略:
1. 保留最近的完整对话
2. 较早的对话进行摘要压缩
"""
# 简单估算1个中文字约等于1.5个token
def estimate_tokens(text: str) -> int:
return len(text) * 1.5
# 从最新开始,保留尽可能多的完整对话
selected = []
total_tokens = 0
for msg in reversed(history):
msg_tokens = estimate_tokens(msg["content"])
if total_tokens + msg_tokens > max_tokens:
# 超出限制,停止
break
selected.insert(0, msg)
total_tokens += msg_tokens
if not selected:
return ""
# 如果有更早的历史被省略,添加提示
if len(selected) < len(history):
omitted_count = len(history) - len(selected)
prefix = f"[省略了 {omitted_count} 条较早的历史消息]\n\n"
else:
prefix = ""
# 格式化
lines = []
for msg in selected:
role_name = "用户" if msg["role"] == "user" else "助手"
lines.append(f"{role_name}{msg['content']}")
return prefix + "\n".join(lines)
def clear_history(self, session_id: str):
"""清空会话历史"""
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('''
DELETE FROM messages WHERE session_id = ?
''', (session_id,))
def delete_session(self, session_id: str):
"""删除会话"""
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('''
DELETE FROM messages WHERE session_id = ?
''', (session_id,))
cursor.execute('''
DELETE FROM sessions WHERE session_id = ?
''', (session_id,))
def cleanup_expired_sessions(self):
"""清理过期会话"""
expire_time = datetime.now() - timedelta(hours=self.session_expire_hours)
with get_connection("session") as conn:
cursor = conn.cursor()
# 删除过期会话的消息
cursor.execute('''
DELETE FROM messages
WHERE session_id IN (
SELECT session_id FROM sessions
WHERE last_active < ?
)
''', (expire_time,))
# 删除过期会话
cursor.execute('''
DELETE FROM sessions
WHERE last_active < ?
''', (expire_time,))
deleted = cursor.rowcount
return deleted
def get_user_sessions(self, user_id: str, limit: int = 10) -> List[Dict]:
"""
获取用户的所有会话
Returns:
[{"session_id": "...", "created_at": "...", "last_active": "..."}, ...]
"""
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT session_id, created_at, last_active, metadata
FROM sessions
WHERE user_id = ?
ORDER BY last_active DESC
LIMIT ?
''', (user_id, limit))
rows = cursor.fetchall()
sessions = []
for row in rows:
sessions.append({
"session_id": row[0],
"created_at": row[1],
"last_active": row[2],
"metadata": json.loads(row[3]) if row[3] else {}
})
return sessions
def get_stats(self) -> Dict:
"""获取统计信息"""
with get_connection("session") as conn:
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM sessions')
total_sessions = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM messages')
total_messages = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(DISTINCT user_id) FROM sessions')
total_users = cursor.fetchone()[0]
return {
"total_sessions": total_sessions,
"total_messages": total_messages,
"total_users": total_users
}
# 测试代码
if __name__ == "__main__":
# 创建会话管理器
sm = SessionManager()
print("=" * 50)
print("会话管理器测试")
print("=" * 50)
# 测试1: 创建会话
print("\n【测试1】创建会话")
user_id = "test_user_001"
session_id = sm.create_session(user_id, {"name": "测试用户"})
print(f"用户ID: {user_id}")
print(f"会话ID: {session_id}")
# 测试2: 添加对话
print("\n【测试2】添加对话")
sm.add_message(session_id, "user", "出差补助标准是什么?")
sm.add_message(session_id, "assistant", "根据公司规定出差补助包括伙食费、交通费和住宿费。伙食补助每天100元...")
sm.add_message(session_id, "user", "那请假流程呢?")
sm.add_message(session_id, "assistant", "请假流程如下1. 提交请假申请...")
print("已添加4条消息")
# 测试3: 获取历史
print("\n【测试3】获取历史")
history = sm.get_history(session_id)
for msg in history:
role = "用户" if msg["role"] == "user" else "助手"
print(f" {role}: {msg['content'][:30]}...")
# 测试4: 构建上下文
print("\n【测试4】构建上下文")
context = sm.build_context(session_id, "婚假有多少天?")
print(context)
# 测试5: 统计信息
print("\n【测试5】统计信息")
stats = sm.get_stats()
print(f" 总会话数: {stats['total_sessions']}")
print(f" 总消息数: {stats['total_messages']}")
print(f" 总用户数: {stats['total_users']}")
# 测试6: 获取用户会话列表
print("\n【测试6】用户会话列表")
sessions = sm.get_user_sessions(user_id)
for s in sessions:
print(f" 会话ID: {s['session_id'][:8]}...")
print(f" 创建时间: {s['created_at']}")
print(f" 最后活跃: {s['last_active']}")
print("\n" + "=" * 50)
print("测试完成")