ead5f83bee
dependencies.py 拆分为 dependencies/ 包; 新增 vision/ragflow_ingestion/neo4j 客户端与 h5_ai_task; alembic 045 图置信度迁移; 响应契约统一收尾。
1197 lines
46 KiB
Python
1197 lines
46 KiB
Python
# =============================================================================
|
||
# 企微IT智能服务台 — 知识库自动迭代服务(Tier0 / T03 核心重写)
|
||
# =============================================================================
|
||
# 说明:知识库自动迭代核心服务。Tier0 重写:真 AI 生成替代占位、置信门控、
|
||
# 审批状态机五态流转、audience 自动标注、Neo4j 图同步。
|
||
#
|
||
# 功能:
|
||
# 1. 分析错误标注的高频问题 + 未命中知识库的会话
|
||
# 2. 调用 WingmanService 真 AI 生成建议(替代 [待AI生成] 占位)
|
||
# 3. 置信门控(confidence < 0.7 → source_failed=True)
|
||
# 4. audience 自动标注(按来源会话类型)
|
||
# 5. 审批状态机五态流转(pending → queued → approved → applied → graph_synced)
|
||
# 6. 审核通过后写 Neo4j 图(D1 解读2 合一)
|
||
# 7. flat KnowledgeBase 落库(降为派生视图)
|
||
# =============================================================================
|
||
|
||
import json
|
||
import logging
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.models.knowledge_base import KnowledgeBase
|
||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||
from app.models.conversation import Conversation
|
||
from app.models.conversation_annotation import ConversationAnnotation
|
||
from app.schemas.enums import (
|
||
AudienceEnum,
|
||
GraphSyncStatusEnum,
|
||
SuggestionStatusEnum,
|
||
SourceTypeEnum,
|
||
is_valid_transition,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class KnowledgeIterationService:
|
||
"""知识库自动迭代服务(Tier0 核心重写)。
|
||
|
||
分析会话标注和会话数据,调用 Dify AI 生成知识库优化建议,
|
||
支持置信门控、audience 自动标注、审批状态机五态流转和 Neo4j 图同步。
|
||
"""
|
||
|
||
def __init__(self):
|
||
"""初始化服务。"""
|
||
# AI 分析 API(复用 Dify Wingman)
|
||
self.ai_api_url = settings.dify_wingman_api_url
|
||
self.ai_api_key = settings.dify_wingman_api_key
|
||
self.ai_timeout = settings.dify_wingman_timeout
|
||
# 置信门控阈值(D3 全局 0.7)
|
||
self.confidence_gate_threshold: float = settings.confidence_gate_threshold
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 核心方法:分析并生成建议
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def generate_knowledge_suggestion(
|
||
self,
|
||
db: AsyncSession,
|
||
source_type: str,
|
||
source_data: List[str],
|
||
reason: str,
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""单会话/单事件的知识建议生成(通道 A 全链路闭环)。
|
||
|
||
供会话关闭、标注触发等场景直接调用,生成单条结构化知识建议。
|
||
状态默认 pending,等待训练师审核。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
source_type: 来源类型(conversation/annotation/ai_uncertain)
|
||
source_data: 来源ID列表(如 [conversation_id])
|
||
reason: 生成理由(如"会话已结单,自动生成")
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 建议对象,Dify不可用或无需建议时返回None
|
||
"""
|
||
# 检查是否已存在待处理的建议(避免重复生成)
|
||
existing = await self._check_existing_suggestion(db, source_data[0] if source_data else "")
|
||
if existing:
|
||
logger.info(f"来源 {source_data} 已有待处理建议,跳过重复生成")
|
||
return None
|
||
|
||
# 根据来源类型调用对应的生成方法
|
||
if source_type == "conversation":
|
||
return await self._generate_new_faq_suggestion(
|
||
db=db,
|
||
source_type=source_type,
|
||
source_data=source_data,
|
||
reason=reason,
|
||
)
|
||
else:
|
||
return await self._generate_update_suggestion(
|
||
db=db,
|
||
source_type=source_type,
|
||
source_data=source_data,
|
||
reason=reason,
|
||
)
|
||
|
||
async def analyze_and_generate_suggestions(
|
||
self,
|
||
db: AsyncSession,
|
||
days: int = 7,
|
||
) -> Dict[str, Any]:
|
||
"""分析并生成知识库优化建议。
|
||
|
||
分析过去N天的标注数据和会话数据,调用 WingmanService 真 AI 生成优化建议。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
days: 分析过去N天的数据,默认7天
|
||
|
||
Returns:
|
||
Dict: {
|
||
"annotations_analyzed": int,
|
||
"conversations_analyzed": int,
|
||
"suggestions_generated": int,
|
||
}
|
||
"""
|
||
result = {
|
||
"annotations_analyzed": 0,
|
||
"conversations_analyzed": 0,
|
||
"suggestions_generated": 0,
|
||
}
|
||
|
||
# 1. 分析错误标注的高频问题
|
||
annotation_suggestions = await self._analyze_annotation_data(db, days)
|
||
result["annotations_analyzed"] = annotation_suggestions.get("analyzed_count", 0)
|
||
result["suggestions_generated"] += annotation_suggestions.get("suggestions_count", 0)
|
||
|
||
# 2. 分析未命中知识库的会话
|
||
conversation_suggestions = await self._analyze_conversation_data(db, days)
|
||
result["conversations_analyzed"] = conversation_suggestions.get("analyzed_count", 0)
|
||
result["suggestions_generated"] += conversation_suggestions.get("suggestions_count", 0)
|
||
|
||
logger.info(
|
||
f"知识库迭代分析完成: "
|
||
f"标注={result['annotations_analyzed']}, "
|
||
f"会话={result['conversations_analyzed']}, "
|
||
f"建议={result['suggestions_generated']}"
|
||
)
|
||
|
||
return result
|
||
|
||
async def _analyze_annotation_data(
|
||
self, db: AsyncSession, days: int
|
||
) -> Dict[str, Any]:
|
||
"""分析标注数据,生成优化建议。"""
|
||
since = datetime.now() - timedelta(days=days)
|
||
|
||
stmt = (
|
||
select(ConversationAnnotation)
|
||
.where(ConversationAnnotation.feedback == "useless")
|
||
.where(ConversationAnnotation.created_at >= since)
|
||
)
|
||
result = await db.execute(stmt)
|
||
annotations = result.scalars().all()
|
||
|
||
if not annotations:
|
||
return {"analyzed_count": 0, "suggestions_count": 0}
|
||
|
||
# 按被标注的消息ID分组,统计高频错误
|
||
message_error_counts: Dict[str, int] = {}
|
||
for ann in annotations:
|
||
msg_id = ann.message_id
|
||
message_error_counts[msg_id] = message_error_counts.get(msg_id, 0) + 1
|
||
|
||
frequent_errors = {
|
||
msg_id: count
|
||
for msg_id, count in message_error_counts.items()
|
||
if count >= 3
|
||
}
|
||
|
||
if not frequent_errors:
|
||
return {"analyzed_count": len(annotations), "suggestions_count": 0}
|
||
|
||
suggestions_count = 0
|
||
for msg_id, error_count in frequent_errors.items():
|
||
suggestion = await self._generate_update_suggestion(
|
||
db=db,
|
||
source_type="annotation",
|
||
source_data=[msg_id],
|
||
reason=f"该AI回复在过去{days}天内被标记为无用{error_count}次",
|
||
)
|
||
if suggestion:
|
||
db.add(suggestion)
|
||
suggestions_count += 1
|
||
|
||
await db.commit()
|
||
return {"analyzed_count": len(annotations), "suggestions_count": suggestions_count}
|
||
|
||
async def _analyze_conversation_data(
|
||
self, db: AsyncSession, days: int
|
||
) -> Dict[str, Any]:
|
||
"""分析会话数据,生成新增FAQ建议。"""
|
||
since = datetime.now() - timedelta(days=days)
|
||
|
||
stmt = (
|
||
select(Conversation)
|
||
.where(Conversation.created_at >= since)
|
||
.where(Conversation.status.in_(["waiting_agent", "agentServing"]))
|
||
)
|
||
result = await db.execute(stmt)
|
||
conversations = result.scalars().all()
|
||
|
||
if not conversations:
|
||
return {"analyzed_count": 0, "suggestions_count": 0}
|
||
|
||
sample_size = min(20, len(conversations))
|
||
sampled = conversations[:sample_size]
|
||
|
||
suggestions_count = 0
|
||
for conv in sampled:
|
||
existing = await self._check_existing_suggestion(db, conv.id)
|
||
if existing:
|
||
continue
|
||
|
||
suggestion = await self._generate_new_faq_suggestion(
|
||
db=db,
|
||
source_type="conversation",
|
||
source_data=[conv.id],
|
||
reason=f"会话'{conv.id}'中AI未能解决问题,需人工介入",
|
||
)
|
||
if suggestion:
|
||
db.add(suggestion)
|
||
suggestions_count += 1
|
||
|
||
await db.commit()
|
||
return {"analyzed_count": len(conversations), "suggestions_count": suggestions_count}
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 辅助方法 — AI 生成
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def _generate_update_suggestion(
|
||
self,
|
||
db: AsyncSession,
|
||
source_type: str,
|
||
source_data: List[str],
|
||
reason: str,
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""生成知识库更新建议(真 AI 生成 — 替代 [待AI生成] 占位)。
|
||
|
||
调用 WingmanService.generate_knowledge_suggestion() 生成结构化建议。
|
||
Dify 不可用时设 source_failed=True,不写伪数据。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
source_type: 来源类型
|
||
source_data: 来源数据
|
||
reason: 生成理由
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 建议对象
|
||
"""
|
||
# 构建对话上下文(从 source_data 中获取消息内容)
|
||
context_messages = await self._build_context_from_source(db, source_type, source_data)
|
||
|
||
# 调用 WingmanService 生成建议
|
||
ai_result: Dict[str, Any] = {}
|
||
source_failed = False
|
||
try:
|
||
from app.services.wingman_service import WingmanService
|
||
|
||
wingman = WingmanService()
|
||
ai_result = await wingman.generate_knowledge_suggestion(context_messages)
|
||
await wingman.close()
|
||
except Exception as e:
|
||
logger.error(f"Dify 知识建议生成失败,标记 source_failed: {e}")
|
||
source_failed = True
|
||
|
||
# 如果 Dify 不可用或返回空内容,标记 source_failed
|
||
if not source_failed and not ai_result.get("title") and not ai_result.get("content"):
|
||
source_failed = True
|
||
logger.warning("Dify 返回空建议内容,标记 source_failed")
|
||
|
||
# 构建 KnowledgeSuggestion 对象
|
||
title = ai_result.get("title", "") if not source_failed else "[生成失败] 优化建议"
|
||
content = ai_result.get("content", "") if not source_failed else reason
|
||
category = ai_result.get("category", "其他")
|
||
tags = ai_result.get("tags", [])
|
||
confidence = ai_result.get("confidence", 0.0)
|
||
issue = ai_result.get("issue", "")
|
||
action = ai_result.get("action", "")
|
||
relation_type = ai_result.get("relation_type", "LEADS_TO")
|
||
parent_issue = ai_result.get("parent_issue", "")
|
||
|
||
# D3 置信门控:confidence < 0.7 标记 source_failed
|
||
if not source_failed and confidence < self.confidence_gate_threshold:
|
||
source_failed = True
|
||
logger.info(
|
||
f"置信度 {confidence} 低于门控阈值 {self.confidence_gate_threshold},"
|
||
f"标记 source_failed"
|
||
)
|
||
|
||
# D8 audience 自动标注
|
||
audience = await self._auto_tag_audience(db, source_type, source_data)
|
||
|
||
suggestion = KnowledgeSuggestion(
|
||
suggestion_type="update",
|
||
status=SuggestionStatusEnum.pending.value,
|
||
title=title,
|
||
content=content,
|
||
category=category,
|
||
tags=tags,
|
||
source_type=source_type,
|
||
source_data=source_data,
|
||
reason=reason,
|
||
confidence=confidence,
|
||
audience=audience.value if isinstance(audience, AudienceEnum) else audience,
|
||
issue=issue,
|
||
action=action,
|
||
relation_type=relation_type,
|
||
parent_issue=parent_issue,
|
||
graph_meta={},
|
||
graph_sync_status=GraphSyncStatusEnum.pending.value,
|
||
source_failed=source_failed,
|
||
)
|
||
|
||
return suggestion
|
||
|
||
async def _generate_new_faq_suggestion(
|
||
self,
|
||
db: AsyncSession,
|
||
source_type: str,
|
||
source_data: List[str],
|
||
reason: str,
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""生成新FAQ建议(真 AI 生成 — 替代 [待AI生成] 占位)。
|
||
|
||
与 _generate_update_suggestion 使用相同的 AI 调用流程,
|
||
但 suggestion_type 固定为 "new_faq"。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
source_type: 来源类型
|
||
source_data: 来源数据
|
||
reason: 生成理由
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 建议对象
|
||
"""
|
||
# 构建对话上下文
|
||
context_messages = await self._build_context_from_source(db, source_type, source_data)
|
||
|
||
# 调用 WingmanService 生成建议
|
||
ai_result: Dict[str, Any] = {}
|
||
source_failed = False
|
||
try:
|
||
from app.services.wingman_service import WingmanService
|
||
|
||
wingman = WingmanService()
|
||
ai_result = await wingman.generate_knowledge_suggestion(context_messages)
|
||
await wingman.close()
|
||
except Exception as e:
|
||
logger.error(f"Dify 知识建议生成失败,标记 source_failed: {e}")
|
||
source_failed = True
|
||
|
||
if not source_failed and not ai_result.get("title") and not ai_result.get("content"):
|
||
source_failed = True
|
||
logger.warning("Dify 返回空建议内容,标记 source_failed")
|
||
|
||
title = ai_result.get("title", "") if not source_failed else "[生成失败] 新FAQ建议"
|
||
content = ai_result.get("content", "") if not source_failed else reason
|
||
category = ai_result.get("category", "其他")
|
||
tags = ai_result.get("tags", [])
|
||
confidence = ai_result.get("confidence", 0.0)
|
||
issue = ai_result.get("issue", "")
|
||
action = ai_result.get("action", "")
|
||
relation_type = ai_result.get("relation_type", "LEADS_TO")
|
||
parent_issue = ai_result.get("parent_issue", "")
|
||
|
||
# D3 置信门控
|
||
if not source_failed and confidence < self.confidence_gate_threshold:
|
||
source_failed = True
|
||
logger.info(
|
||
f"置信度 {confidence} 低于门控阈值 {self.confidence_gate_threshold},"
|
||
f"标记 source_failed"
|
||
)
|
||
|
||
# D8 audience 自动标注
|
||
audience = await self._auto_tag_audience(db, source_type, source_data)
|
||
|
||
suggestion = KnowledgeSuggestion(
|
||
suggestion_type="new_faq",
|
||
status=SuggestionStatusEnum.pending.value,
|
||
title=title,
|
||
content=content,
|
||
category=category,
|
||
tags=tags,
|
||
source_type=source_type,
|
||
source_data=source_data,
|
||
reason=reason,
|
||
confidence=confidence,
|
||
audience=audience.value if isinstance(audience, AudienceEnum) else audience,
|
||
issue=issue,
|
||
action=action,
|
||
relation_type=relation_type,
|
||
parent_issue=parent_issue,
|
||
graph_meta={},
|
||
graph_sync_status=GraphSyncStatusEnum.pending.value,
|
||
source_failed=source_failed,
|
||
)
|
||
|
||
return suggestion
|
||
|
||
async def _build_context_from_source(
|
||
self, db: AsyncSession, source_type: str, source_data: List[str]
|
||
) -> List[Dict[str, Any]]:
|
||
"""从来源数据构建对话上下文消息列表。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
source_type: 来源类型
|
||
source_data: 来源ID列表
|
||
|
||
Returns:
|
||
List[Dict]: 消息历史列表(兼容 WingmanService._build_context_messages 输入格式)
|
||
"""
|
||
context_messages: List[Dict[str, Any]] = []
|
||
try:
|
||
from app.models.message import Message
|
||
|
||
for source_id in source_data:
|
||
if source_type == "conversation":
|
||
stmt = (
|
||
select(Message)
|
||
.where(Message.conversation_id == source_id)
|
||
.order_by(Message.created_at.asc())
|
||
.limit(20)
|
||
)
|
||
result = await db.execute(stmt)
|
||
messages = result.scalars().all()
|
||
for msg in messages:
|
||
context_messages.append({
|
||
"sender_type": getattr(msg, "sender_type", "employee"),
|
||
"content": getattr(msg, "content", ""),
|
||
})
|
||
elif source_type == "annotation":
|
||
# 根据 message_id 获取对应消息
|
||
stmt = select(Message).where(Message.id == source_id)
|
||
result = await db.execute(stmt)
|
||
msg = result.scalar_one_or_none()
|
||
if msg:
|
||
context_messages.append({
|
||
"sender_type": getattr(msg, "sender_type", "ai"),
|
||
"content": getattr(msg, "content", ""),
|
||
})
|
||
except ImportError:
|
||
# Message 模型不可用,返回空上下文
|
||
logger.warning("Message 模型导入失败,使用空上下文")
|
||
except Exception as e:
|
||
logger.warning(f"构建对话上下文失败: {e}")
|
||
|
||
# 如果无上下文,放入 reason 作为最小上下文
|
||
if not context_messages:
|
||
context_messages.append({
|
||
"sender_type": "system",
|
||
"content": f"分析来源: {source_type}, 数据: {source_data}",
|
||
})
|
||
|
||
return context_messages
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 辅助方法 — audience 自动标注(D8)
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def _auto_tag_audience(
|
||
self,
|
||
db: AsyncSession,
|
||
source_type: str,
|
||
source_data: List[str],
|
||
) -> AudienceEnum:
|
||
"""自动标注知识受众类型。
|
||
|
||
D8 判定规则(§8.3):
|
||
- manual / document_ragflow → engineer_workguide
|
||
- annotation / conversation / ai_uncertain → 查会话类型
|
||
- employee 会话 → employee_quick_reply
|
||
- engineer 会话 → engineer_workguide
|
||
- 其他/未知 → employee_quick_reply(保守默认)
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
source_type: 来源类型
|
||
source_data: 来源数据
|
||
|
||
Returns:
|
||
AudienceEnum: 受众类型
|
||
"""
|
||
# 手动录入和 RAGFlow → 工程师作业指导
|
||
if source_type in (SourceTypeEnum.manual.value, SourceTypeEnum.document_ragflow.value):
|
||
return AudienceEnum.engineer_workguide
|
||
|
||
# 会话/标注/AI不确定 → 查 Conversation
|
||
if source_type in (
|
||
SourceTypeEnum.annotation.value,
|
||
SourceTypeEnum.conversation.value,
|
||
SourceTypeEnum.ai_uncertain.value,
|
||
):
|
||
if source_data:
|
||
# 取第一个 source_id 查对应会话
|
||
conv_id = source_data[0]
|
||
try:
|
||
stmt = select(Conversation).where(Conversation.id == conv_id)
|
||
result = await db.execute(stmt)
|
||
conv = result.scalar_one_or_none()
|
||
if conv:
|
||
# Conversation 模型无 session_type 字段,
|
||
# 通过 status/employee_name 推断:
|
||
# waiting_agent/agentServing 视为需要人工介入,
|
||
# 这些更可能是工程师关注的
|
||
# 默认 employee_quick_reply(保守)
|
||
return AudienceEnum.employee_quick_reply
|
||
except Exception as e:
|
||
logger.warning(f"查询会话确定 audience 失败: {e}")
|
||
|
||
# 保守默认:员工快捷回复
|
||
return AudienceEnum.employee_quick_reply
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 置信门控(D3)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def _apply_confidence_gate(self, confidence: Optional[float]) -> bool:
|
||
"""检查置信度是否低于门控阈值。
|
||
|
||
D3 硬约束:confidence < 0.7 → 触发门控。
|
||
|
||
Args:
|
||
confidence: AI 生成的置信度
|
||
|
||
Returns:
|
||
bool: True = 通过门控(置信度达标),False = 未通过
|
||
"""
|
||
if confidence is None:
|
||
return False
|
||
return confidence >= self.confidence_gate_threshold
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 查重
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def _check_existing_suggestion(
|
||
self, db: AsyncSession, source_id: str
|
||
) -> bool:
|
||
"""检查是否已存在相关建议。"""
|
||
stmt = (
|
||
select(KnowledgeSuggestion)
|
||
.where(KnowledgeSuggestion.status == SuggestionStatusEnum.pending.value)
|
||
.where(KnowledgeSuggestion.source_data.contains(source_id))
|
||
)
|
||
result = await db.execute(stmt)
|
||
existing = result.scalars().first()
|
||
return existing is not None
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 知识去重 — Neo4j 图结构相似检测(任务3:P2)
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def find_duplicates(
|
||
self,
|
||
db: AsyncSession,
|
||
issue_name: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
suggestion_id: Optional[str] = None,
|
||
neo4j_client=None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""利用 Neo4j 图结构查询同名或相似节点,检测重复 Issue。
|
||
|
||
查询规则:
|
||
1. 同名 Issue:Neo4j 中已存在 name 完全相同的 Issue 节点
|
||
2. 相似标题:title 文本相似度(基于前缀匹配/包含匹配)
|
||
3. 排除已 reject/expired 的建议关联节点
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
issue_name: 待检查的 Issue 名称(可选)
|
||
title: 待检查的标题(可选,用于文本相似匹配)
|
||
suggestion_id: 当前建议ID(可选,排除自身关联的节点)
|
||
neo4j_client: Neo4j 客户端(提供则查图,不提供则只查 SQL)
|
||
|
||
Returns:
|
||
List[Dict]: 重复项列表,每项包含 {type, name, suggestion, similarity}
|
||
"""
|
||
duplicates: List[Dict[str, Any]] = []
|
||
|
||
# 1. SQL 层面:按 title 模糊匹配已应用的 KB 条目
|
||
if title:
|
||
search_keywords = title[:50] # 取前50字符作关键词
|
||
stmt = (
|
||
select(KnowledgeSuggestion)
|
||
.where(KnowledgeSuggestion.status.in_([
|
||
SuggestionStatusEnum.approved.value,
|
||
SuggestionStatusEnum.applied.value,
|
||
SuggestionStatusEnum.graph_synced.value,
|
||
]))
|
||
.where(KnowledgeSuggestion.title.ilike(f"%{search_keywords[:20]}%"))
|
||
.limit(10)
|
||
)
|
||
if suggestion_id:
|
||
stmt = stmt.where(KnowledgeSuggestion.id != suggestion_id)
|
||
result = await db.execute(stmt)
|
||
sql_duplicates = result.scalars().all()
|
||
for s in sql_duplicates:
|
||
duplicates.append({
|
||
"type": "title_similar",
|
||
"name": s.title,
|
||
"suggestion_id": s.id,
|
||
"suggestion_type": s.suggestion_type,
|
||
"issue": s.issue,
|
||
"action": s.action,
|
||
"status": s.status,
|
||
"similarity": self._calc_similarity(title, s.title),
|
||
"source": "sql",
|
||
})
|
||
|
||
# 2. Neo4j 层面:按 issue_name 查同名节点
|
||
if issue_name and neo4j_client is not None:
|
||
try:
|
||
# 查同名 Issue 节点
|
||
existing_issue = await neo4j_client.find_issue_by_name(issue_name)
|
||
if existing_issue:
|
||
# 查该 Issue 关联的 suggestion
|
||
source_sid = existing_issue.source_suggestion_id
|
||
if source_sid and (not suggestion_id or source_sid != suggestion_id):
|
||
stmt = select(KnowledgeSuggestion).where(
|
||
KnowledgeSuggestion.id == source_sid
|
||
)
|
||
result = await db.execute(stmt)
|
||
related_s = result.scalar_one_or_none()
|
||
if related_s:
|
||
duplicates.append({
|
||
"type": "same_issue",
|
||
"name": issue_name,
|
||
"suggestion_id": related_s.id,
|
||
"suggestion_type": related_s.suggestion_type,
|
||
"issue": related_s.issue,
|
||
"action": related_s.action,
|
||
"status": related_s.status,
|
||
"similarity": 1.0,
|
||
"source": "neo4j",
|
||
})
|
||
|
||
# 3. 查名称相似节点(前缀匹配)
|
||
name_prefix = issue_name[:min(10, len(issue_name))]
|
||
if len(name_prefix) >= 3:
|
||
similar_data = await neo4j_client.execute_read_query(
|
||
"""
|
||
MATCH (i:Issue)
|
||
WHERE i.name STARTS WITH $prefix AND i.name <> $exact_name
|
||
RETURN i.name AS name, i.uuid AS uuid, i.category AS category,
|
||
i.source_suggestion_id AS source_suggestion_id
|
||
LIMIT 5
|
||
""",
|
||
params={"prefix": name_prefix, "exact_name": issue_name},
|
||
)
|
||
for row in similar_data:
|
||
sid = row.get("source_suggestion_id")
|
||
if sid and (not suggestion_id or sid != suggestion_id):
|
||
duplicates.append({
|
||
"type": "similar_issue",
|
||
"name": row["name"],
|
||
"suggestion_id": sid,
|
||
"issue": row["name"],
|
||
"similarity": round(
|
||
self._calc_similarity(issue_name, row["name"]), 2
|
||
),
|
||
"source": "neo4j",
|
||
})
|
||
except Exception as e:
|
||
logger.warning(f"Neo4j 去重查询失败(降级继续): {e}")
|
||
|
||
# 按相似度降序排列
|
||
duplicates.sort(key=lambda x: x.get("similarity", 0), reverse=True)
|
||
return duplicates
|
||
|
||
def _calc_similarity(self, a: str, b: str) -> float:
|
||
"""计算两个文本的简单相似度(前缀匹配 + 长度比)。
|
||
|
||
Args:
|
||
a: 文本A
|
||
b: 文本B
|
||
|
||
Returns:
|
||
float: 相似度(0.0-1.0)
|
||
"""
|
||
if not a or not b:
|
||
return 0.0
|
||
a_lower = a.lower().strip()
|
||
b_lower = b.lower().strip()
|
||
# 完全匹配
|
||
if a_lower == b_lower:
|
||
return 1.0
|
||
# 包含匹配
|
||
if a_lower in b_lower or b_lower in a_lower:
|
||
shorter = min(len(a_lower), len(b_lower))
|
||
longer = max(len(a_lower), len(b_lower))
|
||
return 0.5 + 0.5 * (shorter / longer)
|
||
# 公共前缀匹配
|
||
common_prefix_len = 0
|
||
for ca, cb in zip(a_lower, b_lower):
|
||
if ca == cb:
|
||
common_prefix_len += 1
|
||
else:
|
||
break
|
||
max_len = max(len(a_lower), len(b_lower))
|
||
return round(common_prefix_len / max_len, 2) if max_len > 0 else 0.0
|
||
|
||
async def merge_suggestions(
|
||
self,
|
||
db: AsyncSession,
|
||
primary_id: str,
|
||
duplicate_id: str,
|
||
reviewer_id: str,
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""合并重复建议:将重复建议合并到主建议,重复建议标记为 rejected。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
primary_id: 主建议ID(保留)
|
||
duplicate_id: 重复建议ID(将被标记为 rejected)
|
||
reviewer_id: 审核人ID
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 主建议对象(合并后)
|
||
"""
|
||
# 获取主建议
|
||
primary_stmt = select(KnowledgeSuggestion).where(
|
||
KnowledgeSuggestion.id == primary_id
|
||
)
|
||
result = await db.execute(primary_stmt)
|
||
primary = result.scalar_one_or_none()
|
||
if not primary:
|
||
logger.warning(f"合并失败:主建议 {primary_id} 不存在")
|
||
return None
|
||
|
||
# 获取重复建议
|
||
dup_stmt = select(KnowledgeSuggestion).where(
|
||
KnowledgeSuggestion.id == duplicate_id
|
||
)
|
||
result = await db.execute(dup_stmt)
|
||
duplicate = result.scalar_one_or_none()
|
||
|
||
if duplicate:
|
||
# 合并标签(去重)
|
||
merged_tags = list(set((primary.tags or []) + (duplicate.tags or [])))
|
||
primary.tags = merged_tags
|
||
|
||
# 合并 graph_meta
|
||
if duplicate.graph_meta and primary.graph_meta:
|
||
primary.graph_meta.update(duplicate.graph_meta)
|
||
elif duplicate.graph_meta:
|
||
primary.graph_meta = duplicate.graph_meta
|
||
|
||
# 标记重复建议为 rejected(合并归入)
|
||
duplicate.status = SuggestionStatusEnum.rejected.value
|
||
duplicate.reviewer_id = reviewer_id
|
||
duplicate.reviewed_at = datetime.now()
|
||
duplicate.reject_reason = f"已合并至建议 {primary_id}(去重)"
|
||
db.add(duplicate)
|
||
|
||
logger.info(
|
||
f"知识合并完成: primary={primary_id}, duplicate={duplicate_id}, "
|
||
f"reviewer={reviewer_id}"
|
||
)
|
||
|
||
await db.commit()
|
||
await db.refresh(primary)
|
||
return primary
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 审核与应用 — 审批状态机五态流转(D7)
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def approve_suggestion(
|
||
self,
|
||
db: AsyncSession,
|
||
suggestion_id: str,
|
||
reviewer_id: str,
|
||
neo4j_client=None,
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""审核通过建议并应用到知识库(五态流转 + Neo4j 写图)。
|
||
|
||
审批状态机:
|
||
pending/queued → approved → applied → graph_synced
|
||
|
||
D1 解读2 合一:approved 后直接写 Neo4j 图节点和关系,
|
||
flat KnowledgeBase 降为派生视图,同步创建。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
suggestion_id: 建议ID
|
||
reviewer_id: 审核人ID
|
||
neo4j_client: Neo4j 客户端(可选,提供则自动写图)
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 更新后的建议对象
|
||
"""
|
||
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
|
||
result = await db.execute(stmt)
|
||
suggestion = result.scalar_one_or_none()
|
||
|
||
if not suggestion:
|
||
return None
|
||
|
||
# 校验状态转换合法性
|
||
current_status = SuggestionStatusEnum(suggestion.status)
|
||
target_status = SuggestionStatusEnum.approved
|
||
|
||
if not is_valid_transition(current_status, target_status):
|
||
logger.warning(
|
||
f"无效的状态转换: {current_status.value} → {target_status.value} "
|
||
f"(suggestion_id={suggestion_id})"
|
||
)
|
||
return None
|
||
|
||
# 1. approved — 审批通过
|
||
suggestion.status = SuggestionStatusEnum.approved.value
|
||
suggestion.reviewer_id = reviewer_id
|
||
suggestion.reviewed_at = datetime.now()
|
||
|
||
# 2. applied — 创建 flat KB 条目(派生视图)
|
||
if suggestion.suggestion_type in ("new_faq", "update"):
|
||
kb = KnowledgeBase(
|
||
title=suggestion.title,
|
||
content=suggestion.content,
|
||
category=suggestion.category,
|
||
tags=suggestion.tags,
|
||
graph_sync_status=GraphSyncStatusEnum.pending.value,
|
||
)
|
||
db.add(kb)
|
||
await db.flush() # 获取 kb.id
|
||
|
||
suggestion.status = SuggestionStatusEnum.applied.value
|
||
suggestion.applied_at = datetime.now()
|
||
|
||
# 3. Neo4j 写图(D1 解读2 合一)
|
||
if neo4j_client is not None:
|
||
sync_ok = await self.sync_to_neo4j(neo4j_client, suggestion)
|
||
if sync_ok:
|
||
suggestion.status = SuggestionStatusEnum.graph_synced.value
|
||
suggestion.graph_sync_status = GraphSyncStatusEnum.synced.value
|
||
kb.graph_sync_status = GraphSyncStatusEnum.synced.value
|
||
# 回填图节点 uuid
|
||
if suggestion.issue:
|
||
issue_node = await neo4j_client.find_issue_by_name(suggestion.issue)
|
||
if issue_node:
|
||
kb.graph_node_uuid = issue_node.uuid
|
||
logger.info(f"建议 {suggestion_id} 已同步到 Neo4j 图")
|
||
else:
|
||
suggestion.graph_sync_status = GraphSyncStatusEnum.failed.value
|
||
kb.graph_sync_status = GraphSyncStatusEnum.failed.value
|
||
logger.warning(f"建议 {suggestion_id} Neo4j 图同步失败")
|
||
|
||
await db.commit()
|
||
await db.refresh(suggestion)
|
||
|
||
logger.info(f"建议已审核通过并应用: {suggestion_id}, status={suggestion.status}")
|
||
return suggestion
|
||
|
||
async def reject_suggestion(
|
||
self,
|
||
db: AsyncSession,
|
||
suggestion_id: str,
|
||
reviewer_id: str,
|
||
reject_reason: str,
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""拒绝建议。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
suggestion_id: 建议ID
|
||
reviewer_id: 审核人ID
|
||
reject_reason: 拒绝理由
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 更新后的建议对象
|
||
"""
|
||
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
|
||
result = await db.execute(stmt)
|
||
suggestion = result.scalar_one_or_none()
|
||
|
||
if not suggestion:
|
||
return None
|
||
|
||
suggestion.status = SuggestionStatusEnum.rejected.value
|
||
suggestion.reviewer_id = reviewer_id
|
||
suggestion.reviewed_at = datetime.now()
|
||
suggestion.reject_reason = reject_reason
|
||
|
||
await db.commit()
|
||
await db.refresh(suggestion)
|
||
|
||
logger.info(f"建议已拒绝: {suggestion_id}, 理由: {reject_reason}")
|
||
return suggestion
|
||
|
||
async def rewrite_suggestion(
|
||
self,
|
||
db: AsyncSession,
|
||
suggestion_id: str,
|
||
reviewer_id: str,
|
||
data: Dict[str, Any],
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""训练师改写建议内容(改写后重置为 pending 重新走审批)。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
suggestion_id: 建议ID
|
||
reviewer_id: 审核人ID
|
||
data: 改写数据(title/content/category/tags/confidence/audience/issue/action/等)
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 更新后的建议对象
|
||
"""
|
||
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
|
||
result = await db.execute(stmt)
|
||
suggestion = result.scalar_one_or_none()
|
||
|
||
if not suggestion:
|
||
return None
|
||
|
||
# 更新可改写的字段
|
||
for field in (
|
||
"title", "content", "category", "tags", "confidence",
|
||
"audience", "issue", "action", "relation_type", "parent_issue",
|
||
):
|
||
if field in data and data[field] is not None:
|
||
setattr(suggestion, field, data[field])
|
||
|
||
# 重置为 pending,重新走审批流程
|
||
suggestion.status = SuggestionStatusEnum.pending.value
|
||
suggestion.reviewer_id = reviewer_id
|
||
suggestion.reviewed_at = datetime.now()
|
||
|
||
await db.commit()
|
||
await db.refresh(suggestion)
|
||
|
||
logger.info(f"建议已改写并重置为 pending: {suggestion_id}")
|
||
return suggestion
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 独立队列操作(D7)
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def queue_suggestion(
|
||
self, db: AsyncSession, suggestion_id: str
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""将建议放入独立队列(queued 状态)。
|
||
|
||
当会话关闭且提案仍处于 pending 时调用。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
suggestion_id: 建议ID
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 更新后的建议对象
|
||
"""
|
||
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
|
||
result = await db.execute(stmt)
|
||
suggestion = result.scalar_one_or_none()
|
||
|
||
if not suggestion:
|
||
return None
|
||
|
||
current_status = SuggestionStatusEnum(suggestion.status)
|
||
if not is_valid_transition(current_status, SuggestionStatusEnum.queued):
|
||
logger.warning(
|
||
f"无效的状态转换: {current_status.value} → queued "
|
||
f"(suggestion_id={suggestion_id})"
|
||
)
|
||
return None
|
||
|
||
suggestion.status = SuggestionStatusEnum.queued.value
|
||
suggestion.queued_at = datetime.now()
|
||
|
||
await db.commit()
|
||
await db.refresh(suggestion)
|
||
|
||
logger.info(f"建议已进入独立队列: {suggestion_id}")
|
||
return suggestion
|
||
|
||
async def dequeue_approve(
|
||
self,
|
||
db: AsyncSession,
|
||
suggestion_id: str,
|
||
reviewer_id: str,
|
||
neo4j_client=None,
|
||
) -> Optional[KnowledgeSuggestion]:
|
||
"""从独立队列中审批通过建议(同 approve_suggestion 流程)。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
suggestion_id: 建议ID
|
||
reviewer_id: 审核人ID
|
||
neo4j_client: Neo4j 客户端(可选)
|
||
|
||
Returns:
|
||
Optional[KnowledgeSuggestion]: 更新后的建议对象
|
||
"""
|
||
return await self.approve_suggestion(
|
||
db=db,
|
||
suggestion_id=suggestion_id,
|
||
reviewer_id=reviewer_id,
|
||
neo4j_client=neo4j_client,
|
||
)
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Neo4j 图同步(D1 解读2 合一)
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def sync_to_neo4j(
|
||
self,
|
||
neo4j_client,
|
||
suggestion: KnowledgeSuggestion,
|
||
) -> bool:
|
||
"""将建议写入 Neo4j 图(D1 解读2 合一:图即真相源)。
|
||
|
||
对齐复杂场景重构 v1.1 命名约定:
|
||
- Issue.name / Action.name 作为唯一业务键,MERGE 幂等写入
|
||
- 关系类型对齐 RelationTypeEnum(LEADS_TO / RELATES_TO / CAN_JUMP_TO)
|
||
|
||
写图步骤:
|
||
1. merge_issue → IssueNode
|
||
2. merge_action → ActionNode(如提供)
|
||
3. create_relation → (Issue)-[LEADS_TO/RELATES_TO]->(Action) 或 (Issue)-[:]->(Issue)
|
||
|
||
Args:
|
||
neo4j_client: Neo4jClient 实例
|
||
suggestion: KnowledgeSuggestion 对象
|
||
|
||
Returns:
|
||
bool: 写图成功返回 True,失败返回 False
|
||
"""
|
||
if not suggestion.issue:
|
||
logger.debug(f"建议 {suggestion.id} 无 issue 字段,跳过图同步")
|
||
return True # 无图字段不视为失败
|
||
|
||
try:
|
||
from app.models.neo4j_schema import IssueNode as N4JIssue, ActionNode as N4JAction, RelationEdge
|
||
|
||
# 1. 写入 Issue 节点(MERGE 幂等)
|
||
issue_props: Dict[str, Any] = {
|
||
"source_suggestion_id": suggestion.id,
|
||
}
|
||
if suggestion.graph_meta:
|
||
issue_props.update(suggestion.graph_meta)
|
||
|
||
issue_node = await neo4j_client.merge_issue(
|
||
name=suggestion.issue,
|
||
category=suggestion.category,
|
||
props=issue_props,
|
||
)
|
||
logger.debug(f"Issue 节点已同步: {issue_node.name} (uuid={issue_node.uuid})")
|
||
|
||
# 2. 如果有父 Issue,先写入父 Issue 并创建关系
|
||
if suggestion.parent_issue:
|
||
parent_node = await neo4j_client.merge_issue(
|
||
name=suggestion.parent_issue,
|
||
category=suggestion.category,
|
||
props={"source_suggestion_id": suggestion.id},
|
||
)
|
||
|
||
parent_rel = RelationEdge(
|
||
from_uuid=parent_node.uuid,
|
||
to_uuid=issue_node.uuid,
|
||
type=suggestion.relation_type or "LEADS_TO",
|
||
order=0,
|
||
weight=1.0,
|
||
)
|
||
await neo4j_client.create_relation(
|
||
parent_node.uuid, issue_node.uuid, parent_rel
|
||
)
|
||
logger.debug(
|
||
f"父 Issue 关系已创建: {suggestion.parent_issue} "
|
||
f"-[:{parent_rel.type}]-> {suggestion.issue}"
|
||
)
|
||
|
||
# 3. 写入 Action 节点(如果提供)
|
||
if suggestion.action:
|
||
action_node = await neo4j_client.merge_action(
|
||
name=suggestion.action,
|
||
props={
|
||
"description": suggestion.title,
|
||
"source_suggestion_id": suggestion.id,
|
||
},
|
||
)
|
||
|
||
action_rel = RelationEdge(
|
||
from_uuid=issue_node.uuid,
|
||
to_uuid=action_node.uuid,
|
||
type=suggestion.relation_type or "LEADS_TO",
|
||
order=1,
|
||
weight=suggestion.confidence or 1.0,
|
||
)
|
||
await neo4j_client.create_relation(
|
||
issue_node.uuid, action_node.uuid, action_rel
|
||
)
|
||
logger.debug(
|
||
f"Action 节点已同步: {action_node.name} "
|
||
f"-[:{action_rel.type}]-> 关系已创建"
|
||
)
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"Neo4j 图同步失败 (suggestion_id={suggestion.id}): {e}")
|
||
return False
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 查询统计
|
||
# --------------------------------------------------------------------------
|
||
|
||
async def get_suggestion_stats(self, db: AsyncSession) -> Dict[str, int]:
|
||
"""获取建议统计(含新增状态 queued/graph_synced/expired)。"""
|
||
stmt = select(KnowledgeSuggestion)
|
||
result = await db.execute(stmt)
|
||
all_suggestions = result.scalars().all()
|
||
|
||
stats: Dict[str, int] = {
|
||
"total": len(all_suggestions),
|
||
"pending": 0,
|
||
"queued": 0,
|
||
"approved": 0,
|
||
"rejected": 0,
|
||
"applied": 0,
|
||
"graph_synced": 0,
|
||
"expired": 0,
|
||
"new_faq_count": 0,
|
||
"update_count": 0,
|
||
"outdated_count": 0,
|
||
}
|
||
|
||
for s in all_suggestions:
|
||
if s.status in stats:
|
||
stats[s.status] += 1
|
||
if s.suggestion_type == "new_faq":
|
||
stats["new_faq_count"] += 1
|
||
elif s.suggestion_type == "update":
|
||
stats["update_count"] += 1
|
||
elif s.suggestion_type == "outdated":
|
||
stats["outdated_count"] += 1
|
||
|
||
return stats
|
||
|
||
async def get_queue_stats(self, db: AsyncSession) -> Dict[str, int]:
|
||
"""获取独立队列统计。
|
||
|
||
Returns:
|
||
Dict: {
|
||
"queued_total": int, # 队列中总数
|
||
"pending_total": int, # 待审核总数
|
||
"by_audience": dict, # 按 audience 分组统计
|
||
}
|
||
"""
|
||
# 队列中统计
|
||
queued_stmt = (
|
||
select(KnowledgeSuggestion)
|
||
.where(KnowledgeSuggestion.status == SuggestionStatusEnum.queued.value)
|
||
)
|
||
queued_result = await db.execute(queued_stmt)
|
||
queued = queued_result.scalars().all()
|
||
|
||
pending_stmt = (
|
||
select(KnowledgeSuggestion)
|
||
.where(KnowledgeSuggestion.status == SuggestionStatusEnum.pending.value)
|
||
)
|
||
pending_result = await db.execute(pending_stmt)
|
||
pending = pending_result.scalars().all()
|
||
|
||
# 按 audience 分组
|
||
by_audience: Dict[str, int] = {}
|
||
for s in queued + pending:
|
||
aud = s.audience or "unknown"
|
||
by_audience[aud] = by_audience.get(aud, 0) + 1
|
||
|
||
return {
|
||
"queued_total": len(queued),
|
||
"pending_total": len(pending),
|
||
"by_audience": by_audience,
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# 依赖注入函数
|
||
# =============================================================================
|
||
|
||
|
||
async def dep_knowledge_iteration_service() -> KnowledgeIterationService:
|
||
"""获取知识库迭代服务实例。"""
|
||
return KnowledgeIterationService()
|