Files
wecom_it_smart_desk/backend/app/services/knowledge_iteration_service.py
T

1197 lines
46 KiB
Python
Raw Normal View History

# =============================================================================
# 企微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. 同名 IssueNeo4j 中已存在 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 幂等写入
- 关系类型对齐 RelationTypeEnumLEADS_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()