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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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. 图节点 CRUD(create/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 []
|
||||
@@ -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
|
||||
@@ -54,6 +54,34 @@ class WingmanService:
|
||||
"输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 知识建议生成专用 Prompt(Tier0 / 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
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 内部方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user