v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化

This commit is contained in:
Simon
2026-07-17 23:08:59 +08:00
parent 5a77a89ab1
commit 3ed86d5fb3
181 changed files with 19738 additions and 2655 deletions
+450 -46
View File
@@ -37,6 +37,9 @@ from app.services.routing_service import (
)
from app.services.vision_service import VisionService
from app.services.ws_manager import manager as ws_manager
from app.services.asset_recommend_service import get_asset_recommend_service
from app.services.employee_profile_service import get_employee_profile_service
from app.api.approval import APPROVAL_TEMPLATES
logger = logging.getLogger(__name__)
@@ -213,6 +216,71 @@ async def _enrich_image_content(
return "\n".join(parts)
async def _persist_and_push_solution(
db,
conversation,
employee_id: str,
solution,
):
"""处理图谱命中的解决方案(图谱查询结果)
做什么:
1. 创建AI回复消息记录(图谱命中的解决方案)
2. 通过WS推送给员工端
为什么:图谱命中的解决方案直接返回,不需要调用Dify
Args:
db: 数据库会话
conversation: 会话对象
employee_id: 员工ID
solution: SolutionResult图谱查询结果
"""
from app.models.message import Message
from app.services.ws_manager import manager as ws_manager
# 1. 创建AI回复消息记录
message = Message(
conversation_id=conversation.id,
sender_type="ai",
sender_id="graph",
content=solution.solution,
msg_type="text",
)
db.add(message)
# 更新会话计数
conversation.ai_substantive_reply_count += 1
conversation.updated_at = datetime.now()
await db.commit()
# 2. 构建推送数据
msg_data = {
"id": str(message.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "graph",
"sender_name": "智能助手",
"content": solution.solution,
"msg_type": "text",
"created_at": message.created_at.isoformat(),
"reply_source": "graph_hit", # 标记来源为图谱命中
}
# 3. 通过WS推送给员工端
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": msg_data,
})
logger.info(
f"图谱命中推送成功: employee={employee_id}, "
f"solution={solution.action_name}"
)
except Exception as push_err:
logger.error(f"图谱命中推送失败: {push_err}")
async def _persist_and_push(
db,
conversation: Conversation,
@@ -328,6 +396,12 @@ async def _persist_and_push_structured(
result: get_structured_reply() 返回的结构化结果
"""
text = result.get("text", "")
# ★ 防御性类型保护:确保 content 始终是 String
# 如果 Dify 返回的 text 是 dict/listWS 推送后前端会显示 [object Object]
if not isinstance(text, str):
import json as _json
text = _json.dumps(text, ensure_ascii=False) if text else ""
logger.warning(f"_persist_and_push_structured: text 非 String 类型,已转换: {text[:80]}...")
action = result.get("action")
options = result.get("options")
hit = result.get("hit", False)
@@ -352,18 +426,47 @@ async def _persist_and_push_structured(
should_transfer = not hit
# 确定消息类型
if is_structured and (options or action):
# v2.4 修复:有 action(审批卡片)时,强制设置为 ai_structured
# 确保前端能渲染审批卡片入口,不依赖 Dify 返回的 is_structured 字段
if action or options or is_structured:
msg_type = "ai_structured"
else:
msg_type = "text"
# v2.5 调试日志
logger.info(f"[DEBUG] msg_type = {msg_type}, action = {bool(action)}, options = {bool(options)}, is_structured = {is_structured}")
# 构建 extra_data(存储 options 和 action 供前端渲染)
extra_data = {}
if options:
extra_data["options"] = options
# 为审批卡片注入标准化 card_data(替换原有的分散匹配逻辑)
if action:
approval_type = action.get("approval_type")
title = action.get("title")
# v3.0 重构:委托 ApprovalMatcher 统一完成模板匹配 + 卡片构建
from app.services.approval_matcher import get_approval_matcher
matcher = get_approval_matcher()
matched_card = matcher.match_and_build_card(approval_type, title)
if matched_card:
# 注入 URL 供 approve-direct-card 兼容路径使用
options = matched_card.get("options", [])
if options:
action["url"] = options[0].get("url", "")
action["card_data"] = matched_card
logger.info(f"[ApprovalMatcher] 匹配成功: {approval_type} -> card_type={matched_card.get('card_type')}")
else:
logger.warning(f"[ApprovalMatcher] 匹配失败: approval_type={approval_type}, title={title}")
extra_data["action"] = action
# ★ 调试日志:打印推送到前端的 extra_data 内容
logger.info(f"[DEBUG] 推送到前端的 extra_data: {extra_data}")
if extra_data.get("action"):
logger.info(f"[DEBUG] extra_data.action.approval_type = {extra_data['action'].get('approval_type')}")
# 1. 存 AI 消息
ai_message = Message(
conversation_id=conversation.id,
@@ -397,48 +500,86 @@ async def _persist_and_push_structured(
await db.commit()
# 3. 推 ai_reply 给员工端(聊天气泡:text + options
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(ai_message.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": text,
"msg_type": msg_type,
"extra_data": extra_data if extra_data else None,
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
# Phase 6A: 诊断阶段(前端可据此调整 UI/提示)
"diagnosis_stage": diagnosis_stage,
},
})
# 添加异常处理,避免整体失败
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply",
"data": {
"message_id": str(ai_message.id),
"conversation_id": str(conversation.id),
"sender_type": "ai",
"sender_id": "ai_bot",
"sender_name": "Duckula(达寇拉)",
"content": text,
"msg_type": msg_type,
"extra_data": extra_data if extra_data else None,
"is_guidance": False,
"ai_reply_count": conversation.ai_substantive_reply_count,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
"conversation_status": conversation.status,
# Phase 6A: 诊断阶段(前端可据此调整 UI/提示)
"diagnosis_stage": diagnosis_stage,
},
})
except Exception as emp_err:
# 员工端推送失败不应该导致整个任务失败
logger.warning(f"员工端 AI 回复推送失败(不影响坐席端): {emp_err}")
# 4. 推 dynamic_recommend 给员工端侧边栏(仅当 action 非空时)
# 4. 推 dynamic_recommend 给员工端侧边栏(仅当 action 非空且不是审批类型时)
# 与 ai_reply 同一时刻发出 → 零时间差到达
if action:
# v2.3 修改:审批类型只推送到消息气泡(左边),不推送到侧边栏(右边)
if action and not action.get("approval_type"):
# 为审批卡片注入运维平台跳转URL(实现免登录跳转)
approval_type = action.get("approval_type")
action_url = ""
location = "运维平台"
# 优先精确匹配:直接用 approval_type 查找模板
if approval_type and approval_type in APPROVAL_TEMPLATES:
template = APPROVAL_TEMPLATES[approval_type]
action_url = template.get("url", "")
location = template.get("location", "运维平台")
# 关键字匹配:当精确匹配失败时,通过关键字查找模板
# Dify返回的 approval_type 可能是中文分类名(如"账号权限申请"、"VPN账号申请"
elif approval_type:
for template_id, template in APPROVAL_TEMPLATES.items():
keywords = template.get("keywords", [])
# 检查 approval_type 是否包含任意一个关键字
if any(kw.lower() in approval_type.lower() for kw in keywords):
action_url = template.get("url", "")
location = template.get("location", "运维平台")
break
# v2.2 新增:根据 Dify 返回的 approval_type 设置 filtered_options
filtered_options = []
if approval_type:
filtered_options = [approval_type]
recommend_data = {
"recommend_id": f"rec_{ai_message.id}",
"card_type": action.get("type", "approval_card"),
"title": action.get("title", ""),
"description": action.get("description", ""),
"approval_type": action.get("approval_type"),
"filtered_options": filtered_options, # v2.2: 精确匹配的选项
"action_url": action_url, # 运维平台跳转URL
"action_label": f"打开{location}" if action_url else "打开审批表单", # 按钮文字
"location": location, # 平台名称
"confidence": action.get("confidence", 0.85),
"message_id": str(ai_message.id),
"conversation_id": str(conversation.id),
}
await ws_manager.broadcast_to_employees([employee_id], {
"type": "dynamic_recommend",
"data": recommend_data,
})
logger.info(
f"动态推荐已推送: employee={employee_id}, "
f"card_type={recommend_data['card_type']}, "
f"title={recommend_data['title']}"
)
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "dynamic_recommend",
"data": recommend_data,
})
logger.info(
f"动态推荐已推送: employee={employee_id}, "
f"card_type={recommend_data['card_type']}, "
f"title={recommend_data['title']}"
)
except Exception as rec_err:
logger.warning(f"员工端动态推荐推送失败: {rec_err}")
# 5. 广播坐席端(new_message + conversation_updated
try:
@@ -711,6 +852,147 @@ async def _handle_routing(
return True
async def _enrich_with_last_ai_context(db, conversation_id: str, content: str) -> str:
"""为简短回复拼接对话上下文,弥补 Dify 工作流缺少「对话历史」节点。
v2.3 改进(相对于 v2.2):
- 移除 15 字符硬限制 → 50 字符宽松阈值(问句/换行/长消息自动跳过)
- 查询最近 10 条消息(用户+AI)→ 构建完整对话摘要,含用户原始问题
- 不再依赖 extra_data.options 判断,对所有简短回复尝试拼接
- 跳过刚保存的当前消息避免重复(当前内容已作为 query 单独传给 Dify)
触发条件:消息不含问号/换行、长度 <= 50 字符 → 可能是选项选择/简短回答。
后续:Dify 工作流配置对话历史节点后,可将 `MAX_CONTEXT_LENGTH` 设为 0 来禁用此修复。
返回:拼接后的消息(如果不需要拼接则返回原内容)
"""
# 宽松的启发式判断:不含问号、不含换行、<= 50 字符 → 可能是简短回答
if "?" in content or "" in content or "\n" in content or len(content) > 50:
return content
try:
# 1. 查询最近 10 条消息(按时间倒序索取最新),含用户和 AI
stmt = (
select(Message.content, Message.sender_type, Message.created_at)
.where(Message.conversation_id == conversation_id)
.order_by(Message.created_at.desc())
.limit(10)
)
result = await db.execute(stmt)
rows = list(result.all())
if not rows or len(rows) < 2:
return content # 消息太少,无法构建有意义上下文
# 2. 反转顺序:最早 → 最新
rows.reverse()
# 3. 跳过最后一条员工消息 → 即刚刚保存的当前消息(避免在上下文中重复)
# content 已作为 query 单独发给 Dify,不应出现在上下文中
if rows and rows[-1][1] == "employee":
rows = rows[:-1]
if len(rows) < 2:
return content # 去掉当前消息后没剩几条,不拼接
# 4. 构建对话摘要(最多保留最近 8 条,避免 prompt 过长)
context_lines = []
for row_text, row_sender, _ in rows[-8:]:
if not row_text:
continue
role = "用户" if row_sender == "employee" else "AI助手"
context_lines.append(f"{role}: {row_text}")
if not context_lines:
return content
context = "\n".join(context_lines)
return (
f"【对话上下文】\n{context}\n\n"
f"请根据以上对话历史回答用户的以下消息:{content}"
)
except Exception as e:
logger.warning(f"上下文拼接失败,使用原消息: {e}")
return content
async def _push_asset_recommends(
db,
employee_id: str,
message: str,
dify_result: dict,
):
"""v3.0 资产推荐推送 - 独立于对话的运维触达通道
功能:
1. L1: 从关键词匹配资产(与当前问题相关)
2. L2: 从画像触发运维提醒(与问题无关)
3. L3: 角色通用资源推荐
Args:
db: 数据库会话
employee_id: 员工 ID
message: 用户消息(用于关键词匹配)
dify_result: Dify 返回结果(包含 intent 等信息)
"""
try:
asset_service = get_asset_recommend_service()
profile_service = get_employee_profile_service()
# 1. L1: 关键词匹配(从用户消息中提取关键词)
l1_recs = asset_service.match_keywords(message)
# 2. L2+L3: 画像匹配(需要获取员工画像)
# 为避免每次都调用第三方 API,先尝试获取画像
# 画像获取失败时只推送 L1
profile = None
try:
profile = await profile_service.get_profile(employee_id)
profile_dict = {
'huorong_version': profile.huorong_version,
'huorong_virusdb_date': profile.huorong_virusdb_date,
'huorong_offline_days': profile.huorong_offline_days,
'unionsoft_patches_missing': profile.unionsoft_patches_missing,
'unionsoft_violations': profile.unionsoft_violations,
}
l2_recs = asset_service.match_profile_triggers(profile_dict)
# L3: 角色通用推荐
role = profile.position or ''
l3_recs = asset_service.get_by_role(role)
for rec in l3_recs:
rec.layer = 'L3'
rec.layer_label = '常用资源'
rec.relevance = 'low'
except Exception as e:
logger.warning(f"[AssetRecommend] 获取画像失败: {e}")
l2_recs = []
l3_recs = []
# 3. 合并所有推荐(去重)
all_recs = l1_recs + l2_recs + l3_recs
if not all_recs:
logger.debug(f"[AssetRecommend] 无推荐: employee={employee_id}")
return
# 4. 构建 WS 消息并推送(添加异常处理避免影响主流程)
try:
ws_msg = asset_service.build_ws_message(all_recs)
await ws_manager.broadcast_to_employees([employee_id], ws_msg)
logger.info(
f"[AssetRecommend] 已推送: employee={employee_id}, "
f"L1={len(l1_recs)}, L2={len(l2_recs)}, L3={len(l3_recs)}"
)
except Exception as ws_err:
logger.warning(f"[AssetRecommend] WS推送失败(不影响主流程): {ws_err}")
except Exception as e:
logger.error(f"[AssetRecommend] 推送失败: {e}", exc_info=True)
# 资产推荐失败不影响主对话流程
async def process_h5_ai_reply(
conversation_id: str,
employee_id: str,
@@ -753,9 +1035,19 @@ async def process_h5_ai_reply(
factory = _get_session_factory()
async with factory() as db:
try:
conversation = await db.get(Conversation, conversation_id)
# 防御:会话刚创建时可能事务未提交,最多重试 3 次(每次 0.5s)
conversation = None
for attempt in range(3):
conversation = await db.get(Conversation, conversation_id)
if conversation:
break
if attempt < 2:
await asyncio.sleep(0.5)
# 刷新 session 以看到已提交的数据
await db.rollback()
if not conversation:
logger.warning(f"后台 AI 任务:会话不存在 {conversation_id}")
logger.warning(f"后台 AI 任务:会话不存在(重试3次后) {conversation_id}")
return
# === BYOD 关键词拦截(仅文本消息)===
@@ -814,15 +1106,62 @@ async def process_h5_ai_reply(
# 降级:使用原始 contentAI 会收到 "[图片] 截图" 这样的占位符
# Dify 会回复"我收到了您的截图,请描述一下问题"
# === ★ v2.3 临时修复:Dify 对话历史缺失,为简短回复拼接上下文 ===
# 问题:Dify 工作流未配置「对话历史」节点,conversation_id 传递了但 LLM 看不到历史
# 改进:查询最近 10 条消息(用户+AI)构建完整对话摘要,含用户原始问题
# 触发:消息短(<=50字符)、无问号、无换行 → 拼接后传给 Dify
# 后续:Dify 工作流配置对话历史后可移除此修复
enriched_content = await _enrich_with_last_ai_context(
db, conversation_id, enriched_content
)
# === ★ Neo4j 知识图谱查询(新增 v3.0===
# 做什么:在调用 Dify 之前先查询知识图谱
# 为什么:简单问题可以直接从图谱返回解决方案,响应更快
# 效果:简单问题响应从 3-15秒 → 毫秒级
logger.info(f"图谱检查: msg_type={msg_type}, content_len={len(enriched_content) if enriched_content else 0}")
if msg_type == "text" and enriched_content:
try:
from app.services.graph_query_service import get_graph_query_service
from app.services.neo4j_client import get_neo4j_client
neo4j_client = await get_neo4j_client()
if neo4j_client:
graph_service = await get_graph_query_service(neo4j_client)
if graph_service:
solution = await graph_service.find_solution_by_question(
enriched_content
)
else:
solution = None
if solution:
logger.info(
f"图谱命中: question={enriched_content[:30]}, "
f"solution={solution.action_name}"
)
# 直接返回图谱解决方案,跳过 Dify 调用
await _persist_and_push_solution(
db, conversation, employee_id, solution
)
return
except Exception as graph_err:
# 图谱查询失败不阻断,继续原有 Dify 流程
import traceback
logger.warning(f"图谱查询异常(降级继续): {graph_err}\n{traceback.format_exc()}")
# === ★ v2.0 结构化 AI 回复(替代流式)===
# 1. 立即推送 "正在思考..." 指示器
# 同时推给员工(气泡动画)和坐席(状态指示)
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_thinking",
"data": {
"conversation_id": conversation_id,
},
})
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_thinking",
"data": {
"conversation_id": conversation_id,
},
})
except Exception as thinking_err:
logger.warning(f"推送 AI 思考指示器失败: {thinking_err}")
# 坐席端也通知:AI 正在处理此会话的消息
try:
await ws_manager.broadcast({
@@ -858,15 +1197,40 @@ async def process_h5_ai_reply(
result = await asyncio.wait_for(
ai_handler.ai_service.get_structured_reply(
message=enriched_content,
conversation_id=dify_conversation_id,
conversation_id=dify_conversation_id or conversation.dify_conversation_id,
user_id=employee_id,
),
timeout=30,
)
except asyncio.TimeoutError:
# 30 秒硬超时 → 建议转人工
# v3.0: Dify 超时 → 关键词降级匹配
thinking_task.cancel()
logger.warning(f"Dify 30 秒超时: conversation={conversation_id}")
logger.warning(f"Dify 30 秒超时: conversation={conversation_id},尝试关键词降级")
from app.services.approval_matcher import get_approval_matcher
matcher = get_approval_matcher()
matched_card = matcher.match_by_keywords(content)
if matched_card:
logger.info(f"[Fallback] 关键词降级成功: {matched_card.get('title')}")
fallback_result = {
"text": f"我来帮您提交{matched_card.get('title', '审批')},请点击下方卡片。",
"action": {"card_data": matched_card, "url": matched_card.get("options", [{}])[0].get("url", "")} if matched_card.get("options") else None,
"options": None,
"hit": True,
"conversation_id": conversation.dify_conversation_id,
"is_structured": True,
"diagnosis_stage": None,
"response_time_ms": 0,
"source": "keyword_fallback",
}
try:
await _persist_and_push_structured(db, conversation, employee_id, fallback_result)
return
except Exception as fallback_err:
logger.error(f"[Fallback] 降级推送失败: {fallback_err}")
# 降级也失败 → 建议转人工
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply_failed",
"data": {
@@ -883,13 +1247,53 @@ async def process_h5_ai_reply(
except asyncio.CancelledError:
pass
# v3.1: Dify 返回但无 action(如诊断 escalate/回复"AI服务暂时不可用")→ 关键词降级
# 这是对 v3.0 的补充:v3.0 只在 asyncio.TimeoutError 触发降级,但 Dify LLM 自身可能误判
if not result.get("action"):
from app.services.approval_matcher import get_approval_matcher
matcher = get_approval_matcher()
matched_card = matcher.match_by_keywords(content)
if matched_card:
logger.info(f"[Fallback-v3.1] Dify 无action但关键词命中: {matched_card.get('title')}")
result = {
"text": f"我来帮您提交{matched_card.get('title', '审批')},请点击下方卡片。",
"action": {
"type": "approval_card",
"card_data": matched_card,
"url": matched_card.get("options", [{}])[0].get("url", "") if matched_card.get("options") else "",
},
"options": None,
"hit": True,
"conversation_id": result.get("conversation_id") or conversation.dify_conversation_id,
"is_structured": True,
"diagnosis_stage": "recommending",
"response_time_ms": result.get("response_time_ms", 0),
"source": "keyword_fallback_v3",
}
# 5. 持久化 + 双 WS 推送(ai_reply + dynamic_recommend
await _persist_and_push_structured(
db, conversation, employee_id, result,
)
# 添加单独异常处理,避免影响主流程
try:
await _persist_and_push_structured(
db, conversation, employee_id, result,
)
except Exception as persist_err:
logger.error(f"[Persist] AI回复持久化失败: {persist_err}", exc_info=True)
# 6. v3.0 资产推荐推送(L1/L2/L3 分层)
# 独立于对话的运维触达通道
try:
await _push_asset_recommends(
db, employee_id, content, result,
)
except Exception as asset_err:
logger.error(f"[Asset] 资产推荐推送失败: {asset_err}", exc_info=True)
except Exception as e:
logger.error(f"后台 AI 任务异常: {e}", exc_info=True)
import traceback
# 记录完整的堆栈跟踪信息
tb_str = traceback.format_exc()
logger.error(f"后台 AI 任务异常: {e}\n堆栈跟踪:\n{tb_str}", exc_info=True)
try:
await ws_manager.broadcast_to_employees([employee_id], {
"type": "ai_reply_failed",