feat(backend): knowledge iteration + vision + neo4j + response contract source

dependencies.py 拆分为 dependencies/ 包; 新增 vision/ragflow_ingestion/neo4j 客户端与 h5_ai_task; alembic 045 图置信度迁移; 响应契约统一收尾。
This commit is contained in:
Simon
2026-07-09 11:47:16 +08:00
parent f5374fce9b
commit ead5f83bee
39 changed files with 5276 additions and 545 deletions
+16
View File
@@ -12,6 +12,13 @@ from app.services.session_service import SessionService
from app.services.funny_phrase_service import FunnyPhraseService
from app.services.ai_handler import AIHandler
# Tier0 新增服务导出
from app.services.neo4j_client import Neo4jClient, dep_neo4j_client, get_neo4j_client
from app.services.knowledge_iteration_service import KnowledgeIterationService, dep_knowledge_iteration_service
from app.services.wingman_service import WingmanService
from app.services.vision_service import VisionService
from app.services.ragflow_ingestion_service import RagflowIngestionService
__all__ = [
"WecomService",
"MessageRouter",
@@ -19,4 +26,13 @@ __all__ = [
"SessionService",
"FunnyPhraseService",
"AIHandler",
# Tier0
"Neo4jClient",
"dep_neo4j_client",
"get_neo4j_client",
"KnowledgeIterationService",
"dep_knowledge_iteration_service",
"WingmanService",
"VisionService",
"RagflowIngestionService",
]
+85 -25
View File
@@ -199,36 +199,96 @@ class AIService:
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""调用 Dify API 获取流式 AI 回复(SSE)。
Args:
message: 员工发送的消息内容
conversation_id: Dify 会话ID
user_id: 员工企微 UserID
"""调用 Dify API 获取流式 AI 回复(SSE,逐块 yield 给调用方
Yields:
Dict: {
"delta": str, # 增量内容
"finished": bool, # 是否结束
"conversation_id": str,
"hit": bool, # 最终判断是否命中
}
Dict: {"delta": str, "finished": bool, "conversation_id": str, "hit": bool|None}
- 流式中间块:{"delta": 增量, "finished": False, "hit": None}
- 终态块:{"delta": "", "finished": True, "hit": 命中判断}
做什么:SSE 流式读取 Dify 返回,逐块 yield 给调用方
为什么:
- 流式返回能提升用户体验(不用等 AI 全部生成完才显示
- 通过 WebSocket 推送增量内容到 H5 前端
- 目前第一步先实现非流式,流式作为后续优化
实现:
- stream=True 走 SSE,解析 data: {...} 行,逐块 yield delta
- 流结束后用完整内容整体判断 hit_check_knowledge_hit
容错:若 Dify 不支持流式 / 超时 / 非 SSE 格式,catch 后 fallback 到
get_reply 非流式,yield 一次完整内容(前端退化为"整段到达"
功能不破,仅无逐字动画)。
"""
# TODO: 第一步简化,先 yield 完整内容(非真正流式)
# 后续优化:解析 SSE 事件流,逐块 yield
result = await self.get_reply(message, conversation_id, user_id)
yield {
"delta": result["content"],
"finished": True,
"conversation_id": result["conversation_id"],
"hit": result["hit"],
payload = {
"model": "Chat",
"messages": [{"role": "user", "content": message}],
"stream": True,
"temperature": 0.1,
}
if conversation_id:
payload["conversation_id"] = conversation_id
if user_id:
payload["user"] = user_id
try:
client = await self._get_client()
full_parts: list = []
dify_conv_id = conversation_id or ""
async with client.stream("POST", self.api_url, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
line = line.strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
# OpenAI / Dify SSE 格式:choices[0].delta.content
try:
delta = chunk["choices"][0]["delta"].get("content", "")
except (KeyError, IndexError, TypeError):
delta = ""
if delta:
full_parts.append(delta)
yield {
"delta": delta,
"finished": False,
"conversation_id": dify_conv_id,
"hit": None,
}
# Dify 可能在流式块里给出 conversation_id
cid = chunk.get("conversation_id")
if cid:
dify_conv_id = cid
# 流结束:用完整内容判断命中
full_content = "".join(full_parts)
hit = self._check_knowledge_hit(full_content) if full_content else False
yield {
"delta": "",
"finished": True,
"conversation_id": dify_conv_id,
"hit": hit,
}
except Exception as e:
# 流式不可用(dify2openai 不支持 / 超时 / 非 SSE),回退非流式
logger.warning(f"Dify 流式失败,回退非流式: {e}")
try:
result = await self.get_reply(message, conversation_id, user_id)
yield {
"delta": result["content"],
"finished": True,
"conversation_id": result["conversation_id"],
"hit": result["hit"],
}
except Exception as e2:
logger.error(f"Dify 流式与非流式均失败: {e2}")
yield {
"delta": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
"finished": True,
"conversation_id": conversation_id or "",
"hit": False,
}
# --------------------------------------------------------------------------
# 判断是否命中知识库
@@ -145,22 +145,25 @@ class ContentModerationService:
import re
leaked = []
# 手机号(11 位 1 开头)
if re.search(r"\b1[3-9]\d{9}\b", text):
# 手机号11位1开头
# BUGFIX: \b 和 (?<!\w) 对中文均失效(Python3 \w 含中文),
# 改用 (?<!\d) / (?!\d) 检查数字边界——"电话13800138000" 可正确匹配
if re.search(r"(?<!\d)1[3-9]\d{9}(?!\d)", text):
leaked.append("phone")
# 身份证号(18 位)
if re.search(r"\b\d{17}[\dXx]\b", text):
# 身份证号18位)
if re.search(r"(?<!\d)\d{17}[\dXx](?!\d)", text):
leaked.append("id_card")
# 银行卡(16-19 位连续数字,简单判断)
if re.search(r"\b\d{16,19}\b", text):
# 银行卡16-19位连续数字简单判断
if re.search(r"(?<!\d)\d{16,19}(?!\d)", text):
leaked.append("bank_card")
# 邮箱(个人邮箱,非公司邮箱)
# 邮箱个人邮箱非公司邮箱
# 邮箱以 ASCII 字母开头,(?<!\w) 这里可用(前面不会是中文邮箱前缀)
personal_email_pattern = (
r"\b[a-zA-Z0-9._%+-]+@(?!servyou-it\.com|"
r"servyou\.com\.cn)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b"
r"(?<!\w)[a-zA-Z0-9._%+-]+@(?!servyou-it\.com|"
r"servyou\.com\.cn)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?!\w)"
)
if re.search(personal_email_pattern, text):
leaked.append("personal_email")
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -355,7 +355,7 @@ class MessageRouter:
conversation_id=conversation.id,
sender_type="ai",
sender_id="ai_bot",
sender_name="AI智能助手",
sender_name="Duckula(达寇拉)",
content=reply_text,
msg_type="text",
is_read=False,
@@ -518,7 +518,7 @@ class MessageRouter:
# AI 回复/引导/降级均用 AI 消息类型
sender_type = "ai"
sender_id = "ai_bot"
sender_name = "AI智能助手"
sender_name = "Duckula(达寇拉)"
ai_message = Message(
conversation_id=conversation.id,
+758
View File
@@ -0,0 +1,758 @@
# =============================================================================
# 企微IT智能服务台 — Neo4j 图数据库客户端
# =============================================================================
# 说明:封装 Neo4j 官方异步驱动,提供连接池、读写事务分离、图 schema 初始化、
# 健康检查等基础能力。T01 先实现基础设施,T02 扩展图节点 CRUD。
#
# 核心能力:
# 1. AsyncDriver 连接池管理(initialize / close
# 2. 读写事务分离(execute_write_query / execute_read_query
# 3. 图 schema 初始化(约束 + 索引)
# 4. 健康检查(health_check → RETURN 1
# 5. 图节点 CRUDcreate/merge/find IssueNode / ActionNode / RelationEdge
#
# 图 Schema 对齐:复杂场景重构 v1.1 TeliChat 白盒模型
# 节点:Issue / Action / Info / Session
# 关系:LEADS_TO / RELATES_TO / HAS_ACTION / PROVIDED / CORRECTED_TO
# =============================================================================
import logging
import uuid as _uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from neo4j import AsyncDriver, AsyncGraphDatabase
from neo4j.exceptions import Neo4jError, ServiceUnavailable
from app.config import settings
from app.models.neo4j_schema import ActionNode, IssueNode, RelationEdge
logger = logging.getLogger(__name__)
class Neo4jClient:
"""Neo4j 图数据库异步客户端。
封装 neo4j 官方异步驱动(AsyncDriver),提供连接池管理、
读写事务分离、图 schema 初始化和图节点 CRUD 操作。
使用方式:
client = Neo4jClient()
await client.initialize()
# ... 执行操作 ...
await client.close()
Attributes:
uri: Neo4j bolt 连接地址
user: Neo4j 用户名
password: Neo4j 密码
database: 默认数据库名
_driver: AsyncDriver 实例(懒加载)
"""
# --------------------------------------------------------------------------
# 图 Schema — Cypher 约束与索引
# --------------------------------------------------------------------------
_SCHEMA_CONSTRAINTS: List[str] = [
# Issue 节点唯一约束
"CREATE CONSTRAINT issue_uuid IF NOT EXISTS FOR (i:Issue) REQUIRE i.uuid IS UNIQUE",
# Action 节点唯一约束
"CREATE CONSTRAINT action_uuid IF NOT EXISTS FOR (a:Action) REQUIRE a.uuid IS UNIQUE",
]
_SCHEMA_INDEXES: List[str] = [
# Issue 按分类查询索引
"CREATE INDEX issue_category IF NOT EXISTS FOR (i:Issue) ON (i.category)",
# Issue 按名称查询索引
"CREATE INDEX issue_name IF NOT EXISTS FOR (i:Issue) ON (i.name)",
# Action 按名称查询索引
"CREATE INDEX action_name IF NOT EXISTS FOR (a:Action) ON (a.name)",
]
def __init__(
self,
uri: Optional[str] = None,
user: Optional[str] = None,
password: Optional[str] = None,
database: Optional[str] = None,
max_connection_lifetime: Optional[int] = None,
max_connection_pool_size: Optional[int] = None,
connection_acquisition_timeout: Optional[int] = None,
):
"""初始化 Neo4j 客户端。
所有参数均可选,未提供时从 app.config.settings 读取默认值。
Args:
uri: Neo4j bolt 连接地址
user: Neo4j 用户名
password: Neo4j 密码
database: 默认数据库名
max_connection_lifetime: 连接最大存活时间(秒)
max_connection_pool_size: 连接池上限
connection_acquisition_timeout: 连接获取超时(秒)
"""
self.uri: str = uri or settings.neo4j_uri
self.user: str = user or settings.neo4j_user
self.password: str = password or settings.neo4j_password
self.database: str = database or settings.neo4j_database
self.max_connection_lifetime: int = (
max_connection_lifetime or settings.neo4j_max_connection_lifetime
)
self.max_connection_pool_size: int = (
max_connection_pool_size or settings.neo4j_max_connection_pool_size
)
self.connection_acquisition_timeout: int = (
connection_acquisition_timeout
or settings.neo4j_connection_acquisition_timeout
)
self._driver: Optional[AsyncDriver] = None
# --------------------------------------------------------------------------
# 生命周期管理
# --------------------------------------------------------------------------
async def initialize(self) -> None:
"""初始化 Neo4j 连接并验证可用性。
创建 AsyncDriver 连接池,执行健康检查,创建图 schema(约束+索引)。
Raises:
ServiceUnavailable: Neo4j 服务不可达
Neo4jError: 图 schema 初始化失败
"""
if self._driver is not None:
logger.warning("Neo4jClient 已初始化,跳过重复初始化")
return
logger.info(f"正在初始化 Neo4j 客户端: uri={self.uri}, database={self.database}")
self._driver = AsyncGraphDatabase.driver(
self.uri,
auth=(self.user, self.password),
max_connection_lifetime=self.max_connection_lifetime,
max_connection_pool_size=self.max_connection_pool_size,
connection_acquisition_timeout=self.connection_acquisition_timeout,
)
# 验证连接
healthy = await self.health_check()
if not healthy:
await self._driver.close()
self._driver = None
raise ServiceUnavailable(
f"Neo4j 服务不可达: {self.uri},健康检查失败"
)
# 创建图 schema(约束 + 索引)
await self._init_schema()
logger.info("Neo4j 客户端初始化完成")
async def close(self) -> None:
"""关闭 Neo4j 驱动,释放连接池资源。"""
if self._driver is not None:
await self._driver.close()
self._driver = None
logger.info("Neo4j 客户端已关闭")
async def health_check(self) -> bool:
"""验证 Neo4j 连接可用性。
执行简单的 RETURN 1 Cypher 查询验证连接。
Returns:
bool: 连接正常返回 True,否则 False
"""
if self._driver is None:
return False
try:
result = await self.execute_read_query("RETURN 1 AS ok")
records = [record async for record in result]
return len(records) > 0 and records[0].get("ok") == 1
except Exception as e:
logger.warning(f"Neo4j 健康检查失败: {e}")
return False
async def _init_schema(self) -> None:
"""初始化图 schema:创建约束和索引。
所有约束和索引使用 IF NOT EXISTS,幂等安全。
"""
for cypher in self._SCHEMA_CONSTRAINTS:
try:
await self.execute_write_query(cypher)
logger.debug(f"图约束已创建: {cypher[:60]}...")
except Neo4jError as e:
logger.warning(f"图约束创建失败(可能已存在): {e}")
for cypher in self._SCHEMA_INDEXES:
try:
await self.execute_write_query(cypher)
logger.debug(f"图索引已创建: {cypher[:60]}...")
except Neo4jError as e:
logger.warning(f"图索引创建失败(可能已存在): {e}")
logger.info("图 schema 初始化完成(约束 + 索引)")
# --------------------------------------------------------------------------
# 通用查询方法
# --------------------------------------------------------------------------
async def execute_write_query(
self, cypher: str, params: Optional[Dict[str, Any]] = None
):
"""执行写事务(CREATE/MERGE/DELETE/SET)。
使用 execute_write 自动管理写事务,支持重试策略。
Args:
cypher: Cypher 查询语句
params: 查询参数(可选)
Returns:
查询结果(EagerResult
Raises:
RuntimeError: 客户端未初始化
Neo4jError: 查询执行失败
"""
if self._driver is None:
raise RuntimeError("Neo4jClient 未初始化,请先调用 initialize()")
async def _write(tx):
result = await tx.run(cypher, parameters=params or {})
return await result.data()
async with self._driver.session(database=self.database) as session:
return await session.execute_write(_write)
async def execute_read_query(
self, cypher: str, params: Optional[Dict[str, Any]] = None
):
"""执行读事务(MATCH/RETURN)。
使用 execute_read 自动管理读事务。
Args:
cypher: Cypher 查询语句
params: 查询参数(可选)
Returns:
查询结果(EagerResult
Raises:
RuntimeError: 客户端未初始化
Neo4jError: 查询执行失败
"""
if self._driver is None:
raise RuntimeError("Neo4jClient 未初始化,请先调用 initialize()")
async def _read(tx):
result = await tx.run(cypher, parameters=params or {})
return await result.data()
async with self._driver.session(database=self.database) as session:
return await session.execute_read(_read)
# --------------------------------------------------------------------------
# 图节点 CRUD — IssueNode
# --------------------------------------------------------------------------
async def create_issue_node(self, issue: IssueNode) -> IssueNode:
"""创建 Issue 节点(CREATE,非幂等)。
Args:
issue: IssueNode 实例
Returns:
IssueNode: 创建后的 IssueNode(含 Neo4j 分配的 uuid
"""
props = {
"name": issue.name,
"category": issue.category,
"created_at": (issue.created_at or datetime.now(timezone.utc)).isoformat(),
"updated_at": (issue.updated_at or datetime.now(timezone.utc)).isoformat(),
"source_suggestion_id": issue.source_suggestion_id,
}
data = await self.execute_write_query(
"""
CREATE (i:Issue)
SET i = $props
RETURN i.uuid AS uuid, i.name AS name, i.category AS category,
i.created_at AS created_at, i.updated_at AS updated_at,
i.source_suggestion_id AS source_suggestion_id
""",
params={"props": props},
)
record = data[0]
return IssueNode(
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=record["created_at"],
updated_at=record["updated_at"],
source_suggestion_id=record.get("source_suggestion_id"),
)
async def merge_issue(
self, name: str, category: str, props: Optional[Dict[str, Any]] = None
) -> IssueNode:
"""幂等创建或获取 Issue 节点(MERGE,按 name 匹配)。
使用 MERGE 保证幂等:如果已存在同 name 的 Issue 则返回已有节点,
否则创建新节点。这是图写入的核心方法,对齐 D1「解读2 合一」的去重策略。
Args:
name: Issue 名称(唯一业务键)
category: Issue 分类
props: 额外属性(可选,创建时设置)
Returns:
IssueNode: 创建或获取到的 IssueNode
"""
now = datetime.now(timezone.utc).isoformat()
merge_props = {
"category": category,
"updated_at": now,
}
if props:
merge_props.update(props)
merge_props.setdefault("created_at", now)
data = await self.execute_write_query(
"""
MERGE (i:Issue {name: $name})
ON CREATE SET i.uuid = randomUUID(),
i += $props,
i.created_at = coalesce($props.created_at, $now)
ON MATCH SET i.category = $category,
i.updated_at = $now
RETURN i.uuid AS uuid, i.name AS name, i.category AS category,
i.created_at AS created_at, i.updated_at AS updated_at,
i.source_suggestion_id AS source_suggestion_id
""",
params={
"name": name,
"category": category,
"props": merge_props,
"now": now,
},
)
record = data[0]
return IssueNode(
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=record["created_at"],
updated_at=record["updated_at"],
source_suggestion_id=record.get("source_suggestion_id"),
)
async def find_issue_by_name(self, name: str) -> Optional[IssueNode]:
"""按名称查找 Issue 节点。
Args:
name: Issue 名称
Returns:
Optional[IssueNode]: 找到的 IssueNode,未找到返回 None
"""
data = await self.execute_read_query(
"""
MATCH (i:Issue {name: $name})
RETURN i.uuid AS uuid, i.name AS name, i.category AS category,
i.created_at AS created_at, i.updated_at AS updated_at,
i.source_suggestion_id AS source_suggestion_id
LIMIT 1
""",
params={"name": name},
)
if not data:
return None
record = data[0]
return IssueNode(
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=record["created_at"],
updated_at=record["updated_at"],
source_suggestion_id=record.get("source_suggestion_id"),
)
async def find_related_issues(
self, uuid: str, rel_type: Optional[str] = None
) -> List[IssueNode]:
"""查找与指定 Issue 节点有关联的其他 Issue 节点。
Args:
uuid: 源 Issue 节点的 uuid
rel_type: 关系类型过滤(可选,如 "LEADS_TO"/"RELATES_TO"
Returns:
List[IssueNode]: 关联的 IssueNode 列表
"""
rel_filter = f":{rel_type}" if rel_type else ""
data = await self.execute_read_query(
f"""
MATCH (i:Issue {{uuid: $uuid}})-[{rel_filter}]->(related:Issue)
RETURN related.uuid AS uuid, related.name AS name,
related.category AS category,
related.created_at AS created_at,
related.updated_at AS updated_at,
related.source_suggestion_id AS source_suggestion_id
""",
params={"uuid": uuid},
)
return [
IssueNode(
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=record["created_at"],
updated_at=record["updated_at"],
source_suggestion_id=record.get("source_suggestion_id"),
)
for record in data
]
# --------------------------------------------------------------------------
# 图节点 CRUD — ActionNode
# --------------------------------------------------------------------------
async def create_action_node(self, action: ActionNode) -> ActionNode:
"""创建 Action 节点(CREATE,非幂等)。
Args:
action: ActionNode 实例
Returns:
ActionNode: 创建后的 ActionNode(含 Neo4j 分配的 uuid
"""
props = {
"name": action.name,
"description": action.description,
"created_at": (action.created_at or datetime.now(timezone.utc)).isoformat(),
"source_suggestion_id": action.source_suggestion_id,
}
data = await self.execute_write_query(
"""
CREATE (a:Action)
SET a = $props
RETURN a.uuid AS uuid, a.name AS name,
a.description AS description, a.created_at AS created_at,
a.source_suggestion_id AS source_suggestion_id
""",
params={"props": props},
)
record = data[0]
return ActionNode(
uuid=record["uuid"],
name=record["name"],
description=record.get("description", ""),
created_at=record["created_at"],
source_suggestion_id=record.get("source_suggestion_id"),
)
async def merge_action(
self, name: str, props: Optional[Dict[str, Any]] = None
) -> ActionNode:
"""幂等创建或获取 Action 节点(MERGE,按 name 匹配)。
Args:
name: Action 名称(唯一业务键)
props: 额外属性(可选)
Returns:
ActionNode: 创建或获取到的 ActionNode
"""
now = datetime.now(timezone.utc).isoformat()
merge_props: Dict[str, Any] = {}
if props:
merge_props.update(props)
merge_props.setdefault("created_at", now)
data = await self.execute_write_query(
"""
MERGE (a:Action {name: $name})
ON CREATE SET a.uuid = randomUUID(),
a += $props,
a.created_at = coalesce($props.created_at, $now)
ON MATCH SET a.description = coalesce($props.description, a.description)
RETURN a.uuid AS uuid, a.name AS name,
a.description AS description, a.created_at AS created_at,
a.source_suggestion_id AS source_suggestion_id
""",
params={
"name": name,
"props": merge_props,
"now": now,
},
)
record = data[0]
return ActionNode(
uuid=record["uuid"],
name=record["name"],
description=record.get("description", ""),
created_at=record["created_at"],
source_suggestion_id=record.get("source_suggestion_id"),
)
# --------------------------------------------------------------------------
# 图关系 CRUD — RelationEdge
# --------------------------------------------------------------------------
async def create_relation(
self, from_uuid: str, to_uuid: str, rel: RelationEdge
) -> bool:
"""创建两个节点之间的关系。
支持 Issue→Issue、Issue→Action 的关系创建。
使用 MATCH+CREATE 模式确保节点存在后才建关系。
Args:
from_uuid: 起始节点 uuid
to_uuid: 目标节点 uuid
rel: 关系定义(含 type, order, weight
Returns:
bool: 创建成功返回 True
"""
rel_type = rel.type.value if hasattr(rel.type, 'value') else str(rel.type)
# Neo4j 不支持动态关系类型参数化,使用 f-string(仅 rel_type 来自枚举,安全)
cypher = (
f"MATCH (from_node), (to_node) "
f"WHERE from_node.uuid = $from_uuid AND to_node.uuid = $to_uuid "
f"CREATE (from_node)-[:{rel_type} {{order: $order, weight: $weight}}]->(to_node) "
f"RETURN count(*) AS created"
)
data = await self.execute_write_query(
cypher,
params={
"from_uuid": from_uuid,
"to_uuid": to_uuid,
"order": rel.order,
"weight": rel.weight,
},
)
return data[0].get("created", 0) > 0 if data else False
# --------------------------------------------------------------------------
# 图查询 — 全图导出(任务2:知识图谱可视化)
# --------------------------------------------------------------------------
async def query_full_graph(
self, limit: int = 100
) -> Dict[str, Any]:
"""查询全图节点和关系,返回 ECharts 力导向图格式的 JSON。
查询所有 Issue/Action 节点及其关系,按创建时间降序排列。
用于管理后台知识图谱可视化和坐席审批卡片拓扑预览。
Args:
limit: 返回节点数量上限,默认100
Returns:
Dict: {
"nodes": [{"id": str, "name": str, "category": str, "label": str, "type": str}, ...],
"links": [{"source": str, "target": str, "type": str, "weight": float}, ...],
}
"""
nodes: List[Dict[str, Any]] = []
links: List[Dict[str, Any]] = []
try:
# 查询所有 Issue 节点
issue_data = await self.execute_read_query(
"""
MATCH (i:Issue)
RETURN i.uuid AS id, i.name AS name, i.category AS category,
'issue' AS node_type
ORDER BY i.created_at DESC
LIMIT $limit
""",
params={"limit": limit},
)
for row in issue_data:
nodes.append({
"id": row["id"],
"name": row["name"],
"category": row.get("category", "其他"),
"label": row["name"],
"type": row["node_type"],
})
# 查询所有 Action 节点
action_data = await self.execute_read_query(
"""
MATCH (a:Action)
RETURN a.uuid AS id, a.name AS name, a.description AS description,
'action' AS node_type
ORDER BY a.created_at DESC
LIMIT $limit
""",
params={"limit": limit},
)
for row in action_data:
nodes.append({
"id": row["id"],
"name": row["name"],
"category": row.get("description", ""),
"label": row["name"],
"type": row["node_type"],
})
# 查询所有关系(任意节点之间的有向边)
rel_data = await self.execute_read_query(
"""
MATCH (n)-[r]->(m)
WHERE (n:Issue OR n:Action) AND (m:Issue OR m:Action)
RETURN n.uuid AS source, m.uuid AS target,
type(r) AS rel_type,
coalesce(r.weight, 1.0) AS weight
LIMIT $limit
""",
params={"limit": limit * 3},
)
for row in rel_data:
links.append({
"source": row["source"],
"target": row["target"],
"type": row["rel_type"],
"weight": float(row.get("weight", 1.0)),
})
logger.info(
f"全图查询完成: nodes={len(nodes)}, links={len(links)}"
)
except Exception as e:
logger.error(f"全图查询失败: {e}")
return {"nodes": nodes, "links": links}
async def query_issue_subgraph(
self, issue_name: str, depth: int = 1
) -> Dict[str, Any]:
"""查询指定 Issue 的子图(用于审批卡片拓扑预览)。
以指定 Issue 为中心,向外扩展 depth 层关系。
Args:
issue_name: Issue 名称
depth: 扩展层数,默认1层
Returns:
Dict: {"nodes": [...], "links": [...]}
"""
nodes: List[Dict[str, Any]] = []
links: List[Dict[str, Any]] = []
seen: set = set()
try:
subgraph_data = await self.execute_read_query(
"""
MATCH path = (center:Issue {name: $name})-[*0..%d]-(neighbor)
WHERE neighbor:Issue OR neighbor:Action
WITH nodes(path) AS ns, relationships(path) AS rs
UNWIND ns AS n
WITH DISTINCT n
RETURN n.uuid AS id, labels(n)[0] AS node_type,
coalesce(n.name, n.description, '') AS name,
coalesce(n.category, n.description, '') AS category
LIMIT 30
""" % depth,
params={"name": issue_name},
)
for row in subgraph_data:
nid = row["id"]
if nid not in seen:
seen.add(nid)
nodes.append({
"id": nid,
"name": row["name"],
"category": row.get("category", ""),
"label": row["name"],
"type": row["node_type"].lower() if row["node_type"] else "issue",
})
# 查询子图内的关系
rel_data = await self.execute_read_query(
"""
MATCH (center:Issue {name: $name})-[r*1..%d]-(neighbor)
UNWIND r AS rel
WITH DISTINCT rel
MATCH (n)-[rel]->(m)
RETURN startNode(rel).uuid AS source,
endNode(rel).uuid AS target,
type(rel) AS rel_type,
coalesce(rel.weight, 1.0) AS weight
LIMIT 30
""" % depth,
params={"name": issue_name},
)
for row in rel_data:
links.append({
"source": row["source"],
"target": row["target"],
"type": row["rel_type"],
"weight": float(row.get("weight", 1.0)),
})
except Exception as e:
logger.warning(f"子图查询失败 (issue={issue_name}): {e}")
return {"nodes": nodes, "links": links}
# =============================================================================
# 依赖注入函数
# =============================================================================
# 全局 Neo4jClient 单例(模块级懒加载)
_neo4j_client: Optional[Neo4jClient] = None
async def dep_neo4j_client() -> Neo4jClient:
"""获取 Neo4jClient 单例实例(FastAPI 依赖注入)。
首次调用时自动初始化连接池和图 schema。
应用关闭时需调用 close() 释放资源(见 main.py 生命周期)。
Returns:
Neo4jClient: 已初始化的 Neo4j 客户端实例
Raises:
ServiceUnavailable: Neo4j 服务不可达
"""
global _neo4j_client
if _neo4j_client is None:
_neo4j_client = Neo4jClient()
await _neo4j_client.initialize()
elif not await _neo4j_client.health_check():
# 连接断开后重新初始化
logger.warning("Neo4j 连接已断开,尝试重新初始化")
await _neo4j_client.close()
_neo4j_client = Neo4jClient()
await _neo4j_client.initialize()
return _neo4j_client
async def get_neo4j_client() -> Optional[Neo4jClient]:
"""获取 Neo4jClient(安全版本,不抛异常)。
Neo4j 不可用时返回 None,由调用方做降级处理。
用于非 DI 场景(如 service 层直接调用)。
Returns:
Optional[Neo4jClient]: Neo4j 客户端实例,不可用时返回 None
"""
global _neo4j_client
try:
if _neo4j_client is None:
_neo4j_client = Neo4jClient()
await _neo4j_client.initialize()
return _neo4j_client
except Exception as e:
logger.warning(f"Neo4j 客户端初始化失败(图功能将不可用): {e}")
return None
@@ -0,0 +1,268 @@
# =============================================================================
# 企微IT智能服务台 — RAGFlow 文档 Ingestion 服务(通道 C / P1-5
# =============================================================================
# 说明:封装 RAGFlow 文档上传→ETL→结构化→生成 KnowledgeSuggestion 流程。
# 训练师上传非标准格式文档(.docx/.pdf/.txt/.png/.jpg),
# RAGFlow 做第一道整理/筛选/结构化,产出 KnowledgeSuggestion 进审批队列。
#
# 核心流程:
# 1. upload_and_process: 上传文档→轮询处理状态→拉取结构化片段→生成建议
# 2. poll_processing_status: 轮询 RAGFlow 文档处理状态(最多5分钟)
# 3. create_suggestions_from_result: 结构化片段→KnowledgeSuggestion 列表
#
# 设计决策:
# - 触发方式:训练师手动上传(P1-5 决策)
# - source_type=document_ragflow, audience=engineer_workguide
# - 复用现有 integrations/ragflow/ 客户端基础设施
# =============================================================================
import logging
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from app.config import settings
logger = logging.getLogger(__name__)
class RagflowIngestionService:
"""RAGFlow 文档 Ingestion 服务 — 通道 C。
将非标准格式文档通过 RAGFlow 整理/筛选/结构化,
产出 KnowledgeSuggestion 进入 D7 审批流程。
使用方式:
service = RagflowIngestionService()
result = await service.upload_and_process(file_data, file_name, category_hint)
"""
# 轮询参数
_POLL_INTERVAL_SECONDS: int = 10 # 轮询间隔(秒)
_MAX_WAIT_SECONDS: int = 300 # 最大等待时间(5分钟)
def __init__(self):
"""初始化 RAGFlow Ingestion 服务。"""
self.ragflow_base_url: str = settings.automation_ragflow_base_url
self.ragflow_api_key: str = settings.automation_ragflow_api_key
self.enabled: bool = settings.ragflow_ingestion_enabled
async def upload_and_process(
self,
file_data: bytes,
file_name: str,
category_hint: str = "其他",
) -> Dict[str, Any]:
"""上传文档到 RAGFlow 并等待处理完成,生成 KnowledgeSuggestion 列表。
Args:
file_data: 文件字节流
file_name: 文件名(含扩展名,如 "FAQ更新说明.docx"
category_hint: 分类提示(可选,帮助 RAGFlow 归类)
Returns:
Dict: {
"task_id": str, # RAGFlow 任务ID
"status": str, # "completed" / "failed" / "pending"
"suggestions": list[dict], # KnowledgeSuggestion 列表
}
"""
task_id = str(uuid.uuid4())
if not self.enabled:
logger.info("RAGFlow Ingestion 未启用,返回空结果")
return {
"task_id": task_id,
"status": "disabled",
"suggestions": [],
}
try:
# 1. 上传文档到 RAGFlow
doc_id = await self._upload_document(file_data, file_name)
if not doc_id:
return {
"task_id": task_id,
"status": "failed",
"suggestions": [],
}
# 2. 轮询处理状态
status = await self.poll_processing_status(doc_id)
if status != "completed":
return {
"task_id": task_id,
"status": status,
"suggestions": [],
}
# 3. 拉取结构化片段
chunks = await self._fetch_document_chunks(doc_id)
if not chunks:
return {
"task_id": task_id,
"status": "completed",
"suggestions": [],
}
# 4. 生成 KnowledgeSuggestion
suggestions = self.create_suggestions_from_result(chunks, category_hint)
return {
"task_id": task_id,
"status": "completed",
"suggestions": suggestions,
}
except Exception as e:
logger.error(f"RAGFlow Ingestion 失败: {e}")
return {
"task_id": task_id,
"status": "failed",
"suggestions": [],
}
async def poll_processing_status(
self, doc_id: str, max_wait: int = 300
) -> str:
"""轮询 RAGFlow 文档处理状态。
Args:
doc_id: RAGFlow 文档ID
max_wait: 最大等待时间(秒),默认 300 秒
Returns:
str: "completed" / "failed" / "pending" / "timeout"
"""
import asyncio
elapsed = 0
while elapsed < max_wait:
try:
status = await self._get_document_status(doc_id)
if status == "completed":
logger.info(f"RAGFlow 文档处理完成: doc_id={doc_id}")
return "completed"
if status == "failed":
logger.error(f"RAGFlow 文档处理失败: doc_id={doc_id}")
return "failed"
except Exception as e:
logger.warning(f"轮询 RAGFlow 状态失败: {e}")
await asyncio.sleep(self._POLL_INTERVAL_SECONDS)
elapsed += self._POLL_INTERVAL_SECONDS
logger.debug(
f"轮询 RAGFlow 状态: doc_id={doc_id}, elapsed={elapsed}s"
)
logger.warning(f"RAGFlow 文档处理超时: doc_id={doc_id}")
return "timeout"
def create_suggestions_from_result(
self, chunks: List[Dict[str, Any]], category_hint: str = "其他"
) -> List[Dict[str, Any]]:
"""将 RAGFlow 结构化片段转为 KnowledgeSuggestion 列表。
每个片段生成一个建议,source_type=document_ragflow
audience=engineer_workguide(通道 C 默认工程师作业指导)。
Args:
chunks: RAGFlow 结构化段落列表
category_hint: 分类提示
Returns:
List[Dict]: KnowledgeSuggestion 数据列表(可直接用于创建 DB 记录)
"""
suggestions: List[Dict[str, Any]] = []
for chunk in chunks:
suggestion = {
"suggestion_type": "new_faq",
"title": chunk.get("title", "RAGFlow 提取的知识片段"),
"content": chunk.get("content", ""),
"category": category_hint or chunk.get("category", "其他"),
"tags": chunk.get("tags", []),
"source_type": "document_ragflow",
"source_data": [chunk.get("chunk_id", str(uuid.uuid4()))],
"reason": f"RAGFlow 从文档中提取的结构化知识片段",
"confidence": 0.85, # RAGFlow 结构化提取默认 0.85(§8.2)
"audience": "engineer_workguide", # 通道 C 默认工程师作业指导
"issue": chunk.get("issue", ""),
"action": chunk.get("action", ""),
"relation_type": "LEADS_TO",
"parent_issue": "",
"graph_meta": {},
"graph_sync_status": "pending",
"source_failed": False,
}
suggestions.append(suggestion)
logger.info(
f"RAGFlow 生成 {len(suggestions)} 条 KnowledgeSuggestion"
)
return suggestions
# --------------------------------------------------------------------------
# 内部方法 — RAGFlow API 调用
# --------------------------------------------------------------------------
async def _upload_document(
self, file_data: bytes, file_name: str
) -> Optional[str]:
"""上传文档到 RAGFlow。
Args:
file_data: 文件字节流
file_name: 文件名
Returns:
Optional[str]: RAGFlow 文档ID,失败返回 None
"""
try:
import httpx
from app.integrations.ragflow.client import RagflowClient
# TODO: 接入现有 RagflowClient 实现实际上传
# 当前返回模拟 doc_id(RAGFlow 服务部署后替换为真实调用)
logger.info(
f"RAGFlow 文档上传(模拟): file_name={file_name}, "
f"size={len(file_data)}"
)
doc_id = f"ragflow_doc_{uuid.uuid4().hex[:12]}"
return doc_id
except ImportError:
logger.warning("RAGFlow 客户端不可用,返回模拟 doc_id")
return f"ragflow_doc_{uuid.uuid4().hex[:12]}"
except Exception as e:
logger.error(f"RAGFlow 文档上传失败: {e}")
return None
async def _get_document_status(self, doc_id: str) -> str:
"""查询 RAGFlow 文档处理状态。
Args:
doc_id: RAGFlow 文档ID
Returns:
str: "processing" / "completed" / "failed"
"""
# TODO: 接入现有 RagflowClient 实现状态查询
# 当前返回 completed(占位,RAGFlow 服务部署后替换为真实调用)
logger.debug(f"RAGFlow 文档状态查询(模拟): doc_id={doc_id}")
return "completed"
async def _fetch_document_chunks(
self, doc_id: str
) -> List[Dict[str, Any]]:
"""拉取 RAGFlow 处理后的结构化段落。
Args:
doc_id: RAGFlow 文档ID
Returns:
List[Dict]: 结构化段落列表
"""
# TODO: 接入现有 RagflowClient 实现段落拉取
# 当前返回空列表(占位,RAGFlow 服务部署后替换为真实调用)
logger.info(f"RAGFlow 文档段落拉取(模拟): doc_id={doc_id}")
return []
+292
View File
@@ -0,0 +1,292 @@
# =============================================================================
# 企微IT智能服务台 — 视觉理解服务(D5 / P1-3)
# =============================================================================
# 说明:封装 Qwen-VL 截图理解能力,通过 Dify vision workflow 调用本地
# Qwen3-VL-8B-Instruct 模型,将员工截屏转换为结构化描述文本,
# 注入会话上下文参与后续 AI 推理。
#
# 核心能力:
# 1. analyze_screenshot: 接收图片字节流 → Dify vision workflow → 结构化描述
# 2. _preprocess_image: 图片预处理(resize/compress
# 3. inject_to_conversation_context: 将视觉描述注入会话消息上下文
#
# 设计决策:
# - 视觉理解经 Dify 后端 → Qwen-VL 本地推理(D5 硬约束)
# - 图片预处理:Pillow resize max 1024px + JPEG quality=85
# - 视觉模型可配置(settings.qwen_vl_model,默认 Qwen3-VL-8B-Instruct
# =============================================================================
import base64
import io
import logging
from typing import Any, Dict, Optional
import httpx
from PIL import Image
from app.config import settings
logger = logging.getLogger(__name__)
class VisionService:
"""视觉理解服务 — 截图→结构化描述。
调用本地 Qwen-VL(经 Dify vision workflow)分析员工截图,
生成结构化文本描述并注入会话上下文。
使用方式:
service = VisionService()
result = await service.analyze_screenshot(image_bytes, conversation_id)
await service.inject_to_conversation_context(result["description"], conversation_id)
Attributes:
dify_vision_api_url: Dify Vision Workflow API 端点
dify_vision_api_key: Dify Vision Workflow API Key
model: 视觉模型名称(默认 Qwen3-VL-8B-Instruct
"""
# 图片预处理参数
_MAX_DIMENSION: int = 1024 # 最大边长(像素)
_JPEG_QUALITY: int = 85 # JPEG 压缩质量
def __init__(
self,
dify_vision_api_url: Optional[str] = None,
dify_vision_api_key: Optional[str] = None,
model: Optional[str] = None,
):
"""初始化视觉理解服务。
Args:
dify_vision_api_url: Dify Vision Workflow API 端点
dify_vision_api_key: Dify Vision Workflow API Key
model: 视觉模型名称
"""
self.dify_vision_api_url: str = (
dify_vision_api_url or settings.dify_vision_api_url
)
self.dify_vision_api_key: str = (
dify_vision_api_key or settings.dify_vision_api_key
)
self.model: str = model or settings.qwen_vl_model
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建 httpx 异步客户端。"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(60.0), # 视觉推理可能需要更长时间
headers={
"Authorization": f"Bearer {self.dify_vision_api_key}",
"Content-Type": "application/json",
},
)
return self._client
async def close(self):
"""关闭 httpx 客户端。"""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
# --------------------------------------------------------------------------
# 核心方法
# --------------------------------------------------------------------------
async def analyze_screenshot(
self, image_bytes: bytes, conversation_id: str
) -> Dict[str, Any]:
"""分析截图,返回结构化视觉描述。
Args:
image_bytes: 图片字节流
conversation_id: 会话ID(用于上下文关联)
Returns:
Dict: {
"description": str, # 结构化的视觉描述文本
"confidence": float, # 视觉理解置信度
"metadata": dict, # 元数据(detected_ui_elements, error_codes等)
}
"""
# 默认降级响应
default_response: Dict[str, Any] = {
"description": "",
"confidence": 0.0,
"metadata": {},
}
if not self.dify_vision_api_url:
logger.warning("Dify Vision API 未配置,跳过视觉分析")
return default_response
try:
# 1. 预处理图片
processed_image = await self._preprocess_image(image_bytes)
# 2. 调用 Dify vision workflow
result = await self._call_vision_workflow(processed_image, conversation_id)
if result is None:
return default_response
return {
"description": result.get("description", ""),
"confidence": float(result.get("confidence", 0.0)),
"metadata": result.get("metadata", {}),
}
except Exception as e:
logger.error(f"截图视觉分析失败: {e}")
return default_response
async def inject_to_conversation_context(
self, description: str, conversation_id: str
) -> bool:
"""将视觉描述注入会话消息上下文。
以 system 消息形式将视觉理解结果写入会话消息表,
后续 AI 推理时可读取此描述作为上下文。
Args:
description: 视觉理解描述文本
conversation_id: 会话ID
Returns:
bool: 注入成功返回 True
"""
if not description:
logger.debug("视觉描述为空,跳过上下文注入")
return False
try:
from app.database import _get_session_factory
from app.models.message import Message
session_factory = _get_session_factory()
async with session_factory() as db:
msg = Message(
conversation_id=conversation_id,
sender_type="system",
content=f"[视觉理解] {description}",
)
db.add(msg)
await db.commit()
logger.info(
f"视觉描述已注入会话 {conversation_id}: "
f"description_length={len(description)}"
)
return True
except ImportError:
logger.warning("Message 模型不可用,无法注入视觉描述")
return False
except Exception as e:
logger.error(f"注入视觉描述失败: {e}")
return False
# --------------------------------------------------------------------------
# 内部方法
# --------------------------------------------------------------------------
async def _preprocess_image(self, image_bytes: bytes) -> bytes:
"""预处理图片:resize + compress。
使用 Pillow 将图片缩小到最大 1024px,压缩为 JPEG quality=85
减少传输大小和视觉模型推理开销。
Args:
image_bytes: 原始图片字节流
Returns:
bytes: 预处理后的图片字节流
"""
try:
img = Image.open(io.BytesIO(image_bytes))
# 转换为 RGB(处理 RGBA/PNG 等格式)
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
# 按最大边长等比缩放
w, h = img.size
max_dim = max(w, h)
if max_dim > self._MAX_DIMENSION:
ratio = self._MAX_DIMENSION / max_dim
new_w, new_h = int(w * ratio), int(h * ratio)
img = img.resize((new_w, new_h), Image.LANCZOS)
logger.debug(f"图片缩放: {w}x{h}{new_w}x{new_h}")
# 输出为 JPEG
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=self._JPEG_QUALITY)
result = buffer.getvalue()
logger.debug(
f"图片预处理完成: input_size={len(image_bytes)}, "
f"output_size={len(result)}"
)
return result
except Exception as e:
logger.warning(f"图片预处理失败,使用原始图片: {e}")
return image_bytes
async def _call_vision_workflow(
self, processed_image: bytes, conversation_id: str
) -> Optional[Dict[str, Any]]:
"""调用 Dify Vision Workflow 进行视觉理解。
将预处理后的图片以 base64 格式发送到 Dify vision workflow。
Args:
processed_image: 预处理后的图片字节流
conversation_id: 会话ID
Returns:
Optional[Dict]: 视觉理解结果,失败返回 None
"""
try:
# Base64 编码图片
image_base64 = base64.b64encode(processed_image).decode("utf-8")
payload: Dict[str, Any] = {
"inputs": {
"image_base64": image_base64,
"conversation_id": conversation_id,
},
"response_mode": "blocking",
"user": f"vision-{conversation_id[:8]}",
}
client = await self._get_client()
logger.info(
f"调用 Dify Vision Workflow: conversation_id={conversation_id}, "
f"model={self.model}"
)
response = await client.post(self.dify_vision_api_url, json=payload)
response.raise_for_status()
data = response.json()
# 解析 Dify workflow 返回
outputs = data.get("data", {}).get("outputs", {})
if not outputs:
logger.warning("Dify Vision Workflow 返回空 outputs")
return None
return {
"description": outputs.get("description", ""),
"confidence": float(outputs.get("confidence", 0.0)),
"metadata": outputs.get("metadata", {}),
}
except httpx.TimeoutException:
logger.error("Dify Vision Workflow 超时")
return None
except httpx.HTTPStatusError as e:
logger.error(f"Dify Vision Workflow HTTP 错误: status={e.response.status_code}")
return None
except Exception as e:
logger.error(f"Dify Vision Workflow 调用失败: {e}")
return None
+123
View File
@@ -54,6 +54,34 @@ class WingmanService:
"输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}"
)
# --------------------------------------------------------------------------
# 知识建议生成专用 PromptTier0 / T03 — 通道 A/B 复用)
# --------------------------------------------------------------------------
_KNOWLEDGE_SUGGESTION_PROMPT: str = (
"你是一个IT知识库优化助手,基于对话上下文分析知识库的不足,"
"生成结构化的知识库优化建议。\n\n"
"分析以下对话,判断是否需要新增FAQ或更新已有知识条目。\n"
"如果AI回复被标记为无用,生成更新建议;如果AI无法解决需转人工,生成新增FAQ建议。\n\n"
"必须以JSON格式输出,包含以下字段:\n"
"- suggestion_type: 建议类型,\"new_faq\"\"update\"\n"
"- title: 问题标题(简洁明了)\n"
"- content: 答案内容(分步骤、可操作)\n"
"- category: 分类,只能是 硬件/软件/网络/安全/账号/其他 之一\n"
"- tags: 标签列表,如 [\"VPN\", \"连接\"]\n"
"- confidence: 你对这个建议的信心度,0.0-1.0之间\n"
"- issue: 对应的Neo4j图问题节点名称,如\"VPN问题\"\n"
"- action: 对应的Neo4j图动作节点名称,如\"VPN连接修复\"\n"
"- relation_type: 图关系类型,\"LEADS_TO\"\"RELATES_TO\"\n"
"- parent_issue: 父问题名称(如果没有则为空字符串)\n\n"
"输出格式示例:\n"
"{\"suggestion_type\": \"new_faq\", \"title\": \"VPN连不上怎么办\", "
"\"content\": \"1.检查网络连接 2.重启VPN客户端 3.联系IT支持\", "
"\"category\": \"网络\", \"tags\": [\"VPN\", \"连接\"], "
"\"confidence\": 0.86, \"issue\": \"VPN问题\", "
"\"action\": \"VPN连接修复\", \"relation_type\": \"LEADS_TO\", "
"\"parent_issue\": \"网络问题\"}"
)
def __init__(self):
"""初始化 Wingman 服务。
@@ -264,6 +292,101 @@ class WingmanService:
logger.error(f"Wingman 标签建议失败: {e}")
return default_tags
# --------------------------------------------------------------------------
# 核心方法 4:生成知识库优化建议(Tier0 / T03 — 复用现有范式)
# --------------------------------------------------------------------------
async def generate_knowledge_suggestion(
self,
context_messages: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""生成知识库优化建议(用于知识库自动迭代)。
传入对话上下文,让 Wingman Agent 分析并生成结构化的知识库优化建议。
复用 _build_context_messages + _call_wingman_api + _parse_json_response 范式。
Args:
context_messages: 对话消息历史列表
Returns:
Dict: {
"suggestion_type": str, # "new_faq" / "update"
"title": str, # 问题标题
"content": str, # 答案内容
"category": str, # 分类
"tags": list[str], # 标签列表
"confidence": float, # 置信度(0.0-1.0
"issue": str, # 图节点:问题名称
"action": str, # 图节点:动作名称
"relation_type": str, # 图关系类型
"parent_issue": str, # 父 Issue 名称
}
"""
# 构建对话上下文消息列表(使用知识建议专用 Prompt)
context = self._build_context_messages(
context_messages, self._KNOWLEDGE_SUGGESTION_PROMPT
)
# 默认建议(降级时使用)
default_suggestion: Dict[str, Any] = {
"suggestion_type": "new_faq",
"title": "",
"content": "",
"category": "其他",
"tags": [],
"confidence": 0.0,
"issue": "",
"action": "",
"relation_type": "LEADS_TO",
"parent_issue": "",
}
try:
result = await self._call_wingman_api(context)
if result is None:
logger.warning("Wingman 知识建议生成失败(API 返回 None)")
return default_suggestion
# 尝试解析 JSON 格式的建议
parsed = self._parse_json_response(result, default_suggestion)
# 规范化字段
suggestion: Dict[str, Any] = {
"suggestion_type": parsed.get(
"suggestion_type", default_suggestion["suggestion_type"]
),
"title": parsed.get("title", default_suggestion["title"]),
"content": parsed.get("content", default_suggestion["content"]),
"category": parsed.get("category", default_suggestion["category"]),
"tags": parsed.get("tags", default_suggestion["tags"]),
"confidence": float(parsed.get("confidence", 0.0)),
"issue": parsed.get("issue", default_suggestion["issue"]),
"action": parsed.get("action", default_suggestion["action"]),
"relation_type": parsed.get(
"relation_type", default_suggestion["relation_type"]
),
"parent_issue": parsed.get(
"parent_issue", default_suggestion["parent_issue"]
),
}
# 如果 Dify 未返回 confidence,使用启发式估算
if suggestion["confidence"] == 0.0:
suggestion["confidence"] = self._estimate_confidence(
suggestion["content"]
)
logger.info(
f"知识建议生成完成: type={suggestion['suggestion_type']}, "
f"title={suggestion['title'][:50]}, "
f"confidence={suggestion['confidence']}"
)
return suggestion
except Exception as e:
logger.error(f"Wingman 知识建议生成异常: {e}")
return default_suggestion
# --------------------------------------------------------------------------
# 内部方法
# --------------------------------------------------------------------------