feat: 对话历史页面租户分组展示功能

- 新增 ConversationHistoryManager.get_tenant_summary() 按租户聚合会话统计
- get_sessions_paginated() 和 get_conversation_analytics() 增加 tenant_id 过滤
- 新增 GET /api/conversations/tenants 租户汇总端点
- sessions 和 analytics API 端点支持 tenant_id 查询参数
- 前端实现租户卡片列表视图和租户详情会话表格视图
- 实现面包屑导航、搜索范围限定、统计面板上下文切换
- 会话删除后自动检测空租户并返回列表视图
- dashboard.html 添加租户视图 DOM 容器
- 交互模式与知识库租户分组视图保持一致
This commit is contained in:
2026-04-01 16:11:02 +08:00
parent e14e3ee7a5
commit 7013e9db70
27 changed files with 2753 additions and 276 deletions

View File

@@ -5,7 +5,7 @@ from datetime import datetime
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sqlalchemy import func
from sqlalchemy import func, Integer
from ..core.database import db_manager
from ..core.models import KnowledgeEntry, WorkOrder, Conversation
@@ -162,24 +162,24 @@ class KnowledgeManager:
logger.error(f"查找相似条目失败: {e}")
return None
def search_knowledge(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
def search_knowledge(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""搜索知识库 — 优先使用 embedding 语义检索,降级为关键词匹配"""
try:
# 尝试 embedding 语义检索
if self.embedding_enabled:
results = self._search_by_embedding(query, top_k, verified_only)
results = self._search_by_embedding(query, top_k, verified_only, tenant_id=tenant_id)
if results:
return results
logger.debug("Embedding 检索无结果,降级为关键词匹配")
# 降级:关键词匹配
return self._search_by_keyword(query, top_k, verified_only)
return self._search_by_keyword(query, top_k, verified_only, tenant_id=tenant_id)
except Exception as e:
logger.error(f"搜索知识库失败: {e}")
return []
def _search_by_embedding(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
def _search_by_embedding(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""基于 embedding 向量的语义检索"""
try:
query_vec = self.embedding_client.embed_text(query)
@@ -205,6 +205,8 @@ class KnowledgeManager:
KnowledgeEntry.id.in_(candidate_ids),
KnowledgeEntry.is_active == True
)
if tenant_id is not None:
query_filter = query_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
if verified_only:
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
@@ -212,10 +214,13 @@ class KnowledgeManager:
# 如果 verified_only 没结果,回退到全部
if not entries and verified_only:
entries = session.query(KnowledgeEntry).filter(
fallback_filter = session.query(KnowledgeEntry).filter(
KnowledgeEntry.id.in_(candidate_ids),
KnowledgeEntry.is_active == True
).all()
)
if tenant_id is not None:
fallback_filter = fallback_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
entries = fallback_filter.all()
results = []
for entry in entries:
@@ -240,7 +245,7 @@ class KnowledgeManager:
logger.error(f"Embedding 搜索失败: {e}")
return []
def _search_by_keyword(self, query: str, top_k: int = 3, verified_only: bool = True) -> List[Dict[str, Any]]:
def _search_by_keyword(self, query: str, top_k: int = 3, verified_only: bool = True, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""基于关键词的搜索(降级方案)"""
try:
with db_manager.get_session() as session:
@@ -249,6 +254,9 @@ class KnowledgeManager:
KnowledgeEntry.is_active == True
)
if tenant_id is not None:
query_filter = query_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
# 如果只搜索已验证的知识库
if verified_only:
query_filter = query_filter.filter(KnowledgeEntry.is_verified == True)
@@ -256,7 +264,10 @@ class KnowledgeManager:
entries = query_filter.all()
# 若已验证为空,则回退到全部活跃条目
if not entries and verified_only:
entries = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True).all()
fallback_filter = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True)
if tenant_id is not None:
fallback_filter = fallback_filter.filter(KnowledgeEntry.tenant_id == tenant_id)
entries = fallback_filter.all()
if not entries:
logger.warning("知识库中没有活跃条目")
@@ -334,10 +345,14 @@ class KnowledgeManager:
answer: str,
category: str,
confidence_score: float = 0.5,
is_verified: bool = False
is_verified: bool = False,
tenant_id: Optional[str] = None
) -> bool:
"""添加知识库条目"""
try:
# 确定 tenant_id优先使用传入值否则取配置默认值
effective_tenant_id = tenant_id if tenant_id is not None else get_config().server.tenant_id
# 生成 embedding
embedding_json = None
text_for_embedding = question + " " + answer
@@ -354,6 +369,7 @@ class KnowledgeManager:
confidence_score=confidence_score,
usage_count=0,
is_verified=is_verified,
tenant_id=effective_tenant_id,
vector_embedding=embedding_json
)
session.add(entry)
@@ -541,18 +557,23 @@ class KnowledgeManager:
logger.error(f"删除知识库条目失败: {e}")
return False
def get_knowledge_stats(self) -> Dict[str, Any]:
def get_knowledge_stats(self, tenant_id: Optional[str] = None) -> Dict[str, Any]:
"""获取知识库统计信息"""
try:
with db_manager.get_session() as session:
# 基础过滤条件
base_filter = [KnowledgeEntry.is_active == True]
if tenant_id is not None:
base_filter.append(KnowledgeEntry.tenant_id == tenant_id)
# 只统计活跃(未删除)的条目
total_entries = session.query(KnowledgeEntry).filter(
KnowledgeEntry.is_active == True
*base_filter
).count()
# 统计已验证的条目
verified_entries = session.query(KnowledgeEntry).filter(
KnowledgeEntry.is_active == True,
*base_filter,
KnowledgeEntry.is_verified == True
).count()
@@ -561,27 +582,100 @@ class KnowledgeManager:
KnowledgeEntry.category,
func.count(KnowledgeEntry.id)
).filter(
KnowledgeEntry.is_active == True
*base_filter
).group_by(KnowledgeEntry.category).all()
# 平均置信度(仅限活跃条目)
avg_confidence = session.query(
func.avg(KnowledgeEntry.confidence_score)
).filter(
KnowledgeEntry.is_active == True
*base_filter
).scalar() or 0.0
return {
result = {
"total_entries": total_entries,
"active_entries": verified_entries, # 将 active_entries 复用为已验证数量,或前端相应修改
"category_distribution": dict(category_stats),
"average_confidence": float(avg_confidence)
}
if tenant_id is not None:
result["tenant_id"] = tenant_id
return result
except Exception as e:
logger.error(f"获取知识库统计失败: {e}")
return {}
def get_tenant_summary(self) -> List[Dict[str, Any]]:
"""按 tenant_id 聚合活跃知识条目,返回租户汇总列表。
返回格式: [
{
"tenant_id": "market_a",
"entry_count": 42,
"verified_count": 30,
"category_distribution": {"FAQ": 20, "故障排查": 22}
}, ...
]
按 entry_count 降序排列。
"""
try:
with db_manager.get_session() as session:
# 主聚合查询:按 tenant_id 统计 entry_count 和 verified_count
summary_rows = session.query(
KnowledgeEntry.tenant_id,
func.count(KnowledgeEntry.id).label('entry_count'),
func.sum(
func.cast(KnowledgeEntry.is_verified, Integer)
).label('verified_count')
).filter(
KnowledgeEntry.is_active == True
).group_by(
KnowledgeEntry.tenant_id
).order_by(
func.count(KnowledgeEntry.id).desc()
).all()
if not summary_rows:
return []
# 类别分布查询:按 tenant_id + category 统计
category_rows = session.query(
KnowledgeEntry.tenant_id,
KnowledgeEntry.category,
func.count(KnowledgeEntry.id).label('cat_count')
).filter(
KnowledgeEntry.is_active == True
).group_by(
KnowledgeEntry.tenant_id,
KnowledgeEntry.category
).all()
# 构建 tenant_id -> {category: count} 映射
category_map: Dict[str, Dict[str, int]] = {}
for row in category_rows:
if row.tenant_id not in category_map:
category_map[row.tenant_id] = {}
category_map[row.tenant_id][row.category] = row.cat_count
# 组装结果
result = []
for row in summary_rows:
result.append({
"tenant_id": row.tenant_id,
"entry_count": row.entry_count,
"verified_count": int(row.verified_count or 0),
"category_distribution": category_map.get(row.tenant_id, {})
})
return result
except Exception as e:
logger.error(f"获取租户汇总失败: {e}")
return []
def update_usage_count(self, entry_ids: List[int]) -> bool:
"""更新知识库条目的使用次数"""
try:
@@ -602,12 +696,15 @@ class KnowledgeManager:
logger.error(f"更新知识库使用次数失败: {e}")
return False
def get_knowledge_paginated(self, page: int = 1, per_page: int = 10, category_filter: str = '', verified_filter: str = '') -> Dict[str, Any]:
def get_knowledge_paginated(self, page: int = 1, per_page: int = 10, category_filter: str = '', verified_filter: str = '', tenant_id: Optional[str] = None) -> Dict[str, Any]:
"""获取知识库条目(分页和过滤)"""
try:
with db_manager.get_session() as session:
query = session.query(KnowledgeEntry).filter(KnowledgeEntry.is_active == True)
if tenant_id is not None:
query = query.filter(KnowledgeEntry.tenant_id == tenant_id)
if category_filter:
query = query.filter(KnowledgeEntry.category == category_filter)
if verified_filter: