From b997a683ce6428396be2f4b5d3c0e99fc9f2ae78 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 18 Jul 2026 10:22:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E6=AC=A14=E6=AD=BB=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=A4=A7=E6=89=AB=E9=99=A4:=20=E5=88=A0triage=E4=B8=89?= =?UTF-8?q?=E4=BB=B6=E5=A5=97=EF=BC=88H5=E9=9B=B6=E6=8C=82=E8=BD=BD?= =?UTF-8?q?=EF=BC=89+=20/approval/keywords=E7=AB=AF=E7=82=B9=20+=20schedul?= =?UTF-8?q?er.py=E5=AD=A4=E5=84=BF=E6=A8=A1=E5=9D=97=20+=203=E4=B8=AA.bak?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20+=20get=5Freply=5Fstream=EF=BC=88~96?= =?UTF-8?q?=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../automation-1782986180887/memory.md | 22 + backend/app/api/approval.py | 18 +- .../app/api/approval.py.bak_bridge_1784261164 | 1127 ----------------- backend/app/services/ai_service.py | 99 +- backend/app/tasks/scheduler.py | 149 --- frontend-h5/components.d.ts | 3 - frontend-h5/src/api/triage.ts | 108 -- frontend-h5/src/components/TriageCard.vue | 649 ---------- .../RecommendCard.vue.bak_bridge_1784261164 | 434 ------- ...pprovalCardModal.vue.bak_bridge_1784261164 | 339 ----- frontend-h5/src/composables/useTriage.ts | 318 ----- 11 files changed, 28 insertions(+), 3238 deletions(-) delete mode 100644 backend/app/api/approval.py.bak_bridge_1784261164 delete mode 100644 backend/app/tasks/scheduler.py delete mode 100644 frontend-h5/src/api/triage.ts delete mode 100644 frontend-h5/src/components/TriageCard.vue delete mode 100644 frontend-h5/src/components/assistant/RecommendCard.vue.bak_bridge_1784261164 delete mode 100644 frontend-h5/src/components/chat/ApprovalCardModal.vue.bak_bridge_1784261164 delete mode 100644 frontend-h5/src/composables/useTriage.ts diff --git a/.workbuddy/automations/automation-1782986180887/memory.md b/.workbuddy/automations/automation-1782986180887/memory.md index 8371b93..c939783 100644 --- a/.workbuddy/automations/automation-1782986180887/memory.md +++ b/.workbuddy/automations/automation-1782986180887/memory.md @@ -1,5 +1,27 @@ # 早班巡检自动化 - 执行记录 +## 2026-07-18 09:30 执行结果 + +**数据来源**:主文档第四章 v2.8 (2026-07-14) + 独立看板 `docs/10-项目管理/项目状态看板.md` v1.0 (07-17) + 上次巡检记忆 (07-17) +**说明**:指定路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 连续第10次不存在。独立看板v1.0已更新(#76/#82 07-17入完成区),但主文档第四章未同步 + +### 关键发现 +1. **P0待办2项**:#81敏感词检测+语气优化(阻塞14天,约07-21到期需启动)、#104结构化日志查看页(无阻塞已4天未启动);#117 Neo4j已完成但仍列P0区未清理(数据不一致持续2次巡检) +2. **P1待办3项**:#80坐席图片预览(数据不一致持续2次——已完成区07-16 vs P1清单仍列"待排查")、#73后端文件覆盖、#86流程图review +3. **等用户决策2项,均超3天阈值**:企微会议室Secret(自07-11,7天)、ITSM API授权(自07-11,7天)— 需PM立即关注;联软网络不通标记"暂不处理"不视为卡点 +4. **进行中0项**:主文档和独立看板均为空。上次#76已于07-17完成 +5. **数据质量问题持续**:#81编号冲突(P0敏感词 vs 已完成粘贴图片)、#80/#117双重列出、主文档"已完成"区滞后(07-17 #76/#82未入区) +6. **07-17完成2项**:#76 ITSM工单卡片跳转(桥接页+扫码登录)、#82 H5右侧栏布局调整 — 已入独立看板v1.0 +7. **看板路径第10次缺失**:指定路径连续10次巡检不存在,建议统一看板源 + +### 全局状态 +- P0待办:2项(#81约07-21到期、#104未启动4天) +- P1待办:3项(#80可能已完成待确认) +- 等决策:2项(均超3天阈值,7天) +- 进行中:0项 + +--- + ## 2026-07-17 09:30 执行结果 **数据来源**:主文档第四章 v2.7+ (含07-16更新) + 独立看板 `docs/10-项目管理/项目状态看板.md` v1.0 (07-17) + 记忆文件 (07-16) diff --git a/backend/app/api/approval.py b/backend/app/api/approval.py index 800e9d4..a6aa615 100644 --- a/backend/app/api/approval.py +++ b/backend/app/api/approval.py @@ -983,18 +983,6 @@ async def urge_approval( return success_response(data=result) -@router.get("/approval/keywords") -async def get_approval_keywords(): - """获取所有审批关键词(用于前端关键词检测)""" - keywords = [] - for template in APPROVAL_TEMPLATES.values(): - for kw in template["keywords"]: - keywords.append({ - "keyword": kw, - "template_id": template["id"], - "template_name": template["name"], - "type": template["type"], - }) - return success_response(data=keywords) - - +# v4.0 批次4:GET /approval/keywords 端点已删除 +# (前端 getApprovalKeywords 调用已随 v3.0 ApprovalCardModal 重构移除, +# 仅剩 .bak 备份文件引用;关键词匹配现由 ApprovalMatcher 后端统一处理) diff --git a/backend/app/api/approval.py.bak_bridge_1784261164 b/backend/app/api/approval.py.bak_bridge_1784261164 deleted file mode 100644 index a888d6c..0000000 --- a/backend/app/api/approval.py.bak_bridge_1784261164 +++ /dev/null @@ -1,1127 +0,0 @@ -# ============================================================================= -# IT智能服务台 — 审批流程 API -# ============================================================================= -# 说明:提供审批模板管理和API提交功能 -# - 模板详情获取 -# - API提交审批申请 -# - 审批状态回调处理 -# ============================================================================= - -import asyncio -import json -import logging -import os -from typing import Optional - -import httpx -from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel -import redis.asyncio as aioredis - -from app.config import settings -from app.services.asset_service import AssetService -from app.services.wecom_service import WecomService -from app.utils.token_manager import TokenManager -from app.utils.response import success_response - -logger = logging.getLogger(__name__) -router = APIRouter() - -# IT资产升级申请模板ID(回调时用于识别审批类型,触发年限核查推送) -ASSET_UPGRADE_TEMPLATE_ID = "Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS" - -# Redis客户端(依赖注入) -async def get_redis() -> aioredis.Redis: - """获取Redis客户端依赖""" - from app.main import redis_client - return redis_client - - -# ============================================================================= -# 审批模板配置(静态硬编码 — 18个审批流程,12种审批类型) -# ============================================================================= -# 数据来源:docs/02-产品需求/approval_templates.json -# 所有模板 type 统一为 "jump"(直接URL跳转) - -APPROVAL_TEMPLATES: dict[str, dict] = { - # --- 办公用品申请 --- - "office_supplies": { - "id": "office_supplies", - "name": "办公用品超额领用审批", - "type": "jump", - "keywords": ["办公用品", "超额", "领用"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WN6zRucbjycdnR94gBvkSVuXRamX7pKW4PrmNFh&sp_id=&from=template_list", - "location": "企微审批", - }, - # --- 设备申请 --- - # IT资产领用 改用ITSM工单系统(企微审批模板 C4c8qt31... 已失效) - "asset_receive": { - "id": "asset_receive", - "name": "IT资产领用申请", - "type": "jump", - "keywords": ["资产领用", "领用登记", "设备领用"], - "url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/", - "location": "运维平台", - }, - # IT资产借用 改用ITSM工单系统(企微审批模板 3TmACnFs... 已失效) - "asset_borrow": { - "id": "asset_borrow", - "name": "IT资产借用申请", - "type": "jump", - "keywords": ["资产借用", "借用", "借用设备"], - "url": "https://itsm.servyou.com.cn/itsm-miniapp-mobile/", - "location": "运维平台", - }, - # IT资产升级 改用ITSM工单系统(企微审批模板 Bs7ucTGs... 已失效) - "asset_upgrade": { - "id": "asset_upgrade", - "name": "IT资产升级申请", - "type": "jump", - "keywords": ["资产升级", "设备升级", "升级"], - "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", - "location": "运维平台", - }, - "it_device_repair": { - "id": "it_device_repair", - "name": "IT设备升级与硬件维修", - "type": "jump", - "keywords": ["设备升级", "硬件维修", "设备维修"], - "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE", - "location": "运维平台", - }, - # --- 账号权限申请 --- - "wecom_external": { - "id": "wecom_external", - "name": "企微外联权限申请", - "type": "jump", - "keywords": ["外联", "企微外联", "外部联系人"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WrCbZd214XrDMZJiHDho7ZQHWX7gsabb7x2fF72&sp_id=&from=template_list", - "location": "企微审批", - }, - "zero_trust_vpn": { - "id": "zero_trust_vpn", - "name": "员工零信任(原VPN)账号", - "type": "jump", - "keywords": ["VPN", "vpn", "零信任", "VPN账号"], - "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", - "location": "运维平台", - }, - # --- 软件服务申请 --- 改用ITSM工单系统(企微审批模板 3TmACf8D... 已失效) - "software_service": { - "id": "software_service", - "name": "商业软件服务申请", - "type": "jump", - "keywords": ["软件", "商业软件", "软件服务"], - "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%95%86%E4%B8%9A%E8%BD%AF%E4%BB%B6%E6%9C%8D%E5%8A%A1%E7%94%B3%E8%AF%B7", - "location": "运维平台", - }, - # --- 资产处置申请 --- - "asset_repair": { - "id": "asset_repair", - "name": "IT资产外修申请", - "type": "jump", - "keywords": ["外修", "资产外修", "设备维修"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X&sp_id=&from=template_list", - "location": "企微审批", - }, - "asset_scrap": { - "id": "asset_scrap", - "name": "IT资产报废申请", - "type": "jump", - "keywords": ["报废", "资产报废", "设备报废"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WroCDfWuHKyujQjatjm3AjNv67imXk5C6WNooFkb&sp_id=&from=template_list", - "location": "企微审批", - }, - "asset_return": { - "id": "asset_return", - "name": "资产退还登记", - "type": "jump", - "keywords": ["退还", "资产退还", "退还设备"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt33AZ52a7n9BBWDh6PmsDnpM5B6w8geqqqoHz&sp_id=&from=template_list", - "location": "企微审批", - }, - # --- 会议室故障报修 --- - "meeting_room_repair": { - "id": "meeting_room_repair", - "name": "会议室故障报修", - "type": "jump", - "keywords": ["会议室", "故障报修", "报修"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4ZXJMtjQJiPXo6N5vNMK26uPRT3KTi9VvkH2NScg&sp_id=&from=template_list", - "location": "企微审批", - }, - # --- 企业应用管理 --- - "app_management": { - "id": "app_management", - "name": "企业应用管理", - "type": "jump", - "keywords": ["企业应用", "应用管理", "应用开通"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WLJnRg2Se1fwizQtNvFtcYMgci1mhRJZhMw2FFKb&sp_id=&from=template_list", - "location": "企微审批", - }, - # --- 资产变更确认 --- - "asset_change": { - "id": "asset_change", - "name": "资产变更确认", - "type": "jump", - "keywords": ["资产变更", "变更确认", "变更"], - "url": "https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4cA2owjRcXRRPQ46otZvUHoWNEKL5t25tHHfeePip&sp_id=&from=template_list", - "location": "企微审批", - }, - # --- 终端设备网络准入 --- - "network_access": { - "id": "network_access", - "name": "终端设备网络准入申请", - "type": "jump", - "keywords": ["网络准入", "终端准入", "准入申请"], - "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7", - "location": "运维平台", - }, - # --- 活动与会议技术支持 --- - "event_support": { - "id": "event_support", - "name": "活动与会议技术支持", - "type": "jump", - "keywords": ["活动支持", "会议支持", "技术支持", "活动技术"], - "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81", - "location": "运维平台", - }, - # --- 员工IT支持与故障报修 --- - "it_support_repair": { - "id": "it_support_repair", - "name": "员工IT支持与故障报修", - "type": "jump", - "keywords": ["故障报修", "IT支持", "技术支持", "报修"], - "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE", - "location": "运维平台", - }, - # --- 公共邮箱账号申请 --- - "public_email": { - "id": "public_email", - "name": "公共邮箱账号申请", - "type": "jump", - "keywords": ["公共邮箱", "公共账号", "共享邮箱"], - "url": "http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7", - "location": "运维平台", - }, -} - - -# ============================================================================= -# 审批意图识别 — 关键词预过滤 & 降级兜底 -# ============================================================================= -# 说明:覆盖 12 种审批类型、18 个审批流程的关键词,用于快速过滤非审批消息, -# 避免 Dify 被每条消息调用。当 Dify 不可用时降级为关键词匹配。 - -# 审批关键词预过滤列表(v2.0 收窄版 — 2026-07-13) -# ------------------------------------------------------------ -# 设计原则:只保留「强意图词」和「复合专有词」,移除高频泛化词。 -# -# 改造前 ~40 词(含"设备""电脑""邮箱""权限""软件""报修"等), -# 几乎覆盖所有 IT 消息 → 预过滤命中率 ~60% → 大量无效 Dify 调用。 -# -# 改造后策略: -# - 强意图动词:用户明确表达"申请/提交"意愿时才触发 -# - 复合专有词:仅在审批场景出现,不会在普通 IT 咨询中出现 -# - 移除的泛化词(设备/电脑/邮箱/权限/软件/报修/变更/会议室等) -# 改由 Dify 意图识别内部判断,不作为预过滤触发条件 -# -# 预期效果:预过滤命中率从 ~60% 降至 ~15%,减少 75% 无效 Dify 调用 -APPROVAL_PREFILTER_KEYWORDS: list[str] = [ - # === 强意图动词/短语(用户明确表达申请意愿)=== - "申请", "审批", "提交", "表单", "走流程", - "帮我申请", "我要申请", "需要申请", "想申请", - # === 复合专有词(仅在审批场景出现,日常 IT 咨询不会用)=== - "资产领用", "资产借用", "资产升级", "资产变更", "资产处置", - "网络准入", "终端准入", - "办公用品", "超额领用", - "企业应用管理", "应用开通", - "公共邮箱", "共享邮箱", "公共账号", - "故障报修", "IT支持", - "自备电脑", "电脑补贴", "BYOD", "byod", -] - -# 关键词到审批类型的映射(Dify 不可用时降级兜底用) -KEYWORD_TO_APPROVAL_TYPE: dict[str, str] = { - # 设备申请 - "设备": "设备申请", "电脑": "设备申请", "笔记本": "设备申请", - "显示器": "设备申请", "资产领用": "设备申请", "资产借用": "设备申请", "资产升级": "设备申请", - # 账号权限申请 - "VPN": "账号权限申请", "vpn": "账号权限申请", "账号": "账号权限申请", - "邮箱": "账号权限申请", "外联": "账号权限申请", "权限": "账号权限申请", - # 软件服务申请 - "软件": "软件服务申请", "业务系统": "软件服务申请", - # 资产处置申请 - "外修": "资产处置申请", "报废": "资产处置申请", "退还": "资产处置申请", - # 办公用品申请 - "办公用品": "办公用品申请", "超额": "办公用品申请", - # 会议室故障报修 - "会议室": "会议室故障报修", "故障报修": "会议室故障报修", "报修": "会议室故障报修", - # 企业应用管理 - "企业应用": "企业应用管理", "应用管理": "企业应用管理", "应用开通": "企业应用管理", - # 资产变更确认 - "资产变更": "资产变更确认", "变更确认": "资产变更确认", "变更": "资产变更确认", - # 终端设备网络准入 - "网络准入": "终端设备网络准入", "终端准入": "终端设备网络准入", "准入申请": "终端设备网络准入", - # 活动与会议技术支持 - "活动支持": "活动与会议技术支持", "会议支持": "活动与会议技术支持", "活动技术": "活动与会议技术支持", - # 员工IT支持与故障报修 - "IT支持": "员工IT支持与故障报修", - # 公共邮箱账号申请 - "公共邮箱": "公共邮箱账号申请", "公共账号": "公共邮箱账号申请", "共享邮箱": "公共邮箱账号申请", -} - - -# ============================================================================= -# Schema 定义 -# ============================================================================= - -class ApprovalTemplateResponse(BaseModel): - """审批模板响应""" - id: str - name: str - type: str - keywords: list[str] - - -class ApprovalJumpRequest(BaseModel): - """跳转审批请求""" - template_id: str - employee_id: Optional[str] = None - - -class ApprovalJumpResponse(BaseModel): - """跳转审批响应""" - url: str - template_name: str - - -class ApprovalContentItem(BaseModel): - """审批表单控件内容""" - control: str # 控件类型: Text, Textarea, Number, Money, Date, Selector, Contact, etc. - id: str # 控件ID - value: dict # 控件值 - - -class ApprovalSubmitRequest(BaseModel): - """API提交审批请求""" - template_id: str - employee_id: str # 申请人userid - contents: list[ApprovalContentItem] # 表单内容 - use_template_approver: int = 1 # 1-使用模板预设流程 - - -class ApprovalSubmitResponse(BaseModel): - """API提交审批响应""" - sp_no: str # 审批单号 - template_name: str - - -class ApprovalCallbackRequest(BaseModel): - """审批回调请求(XML解析后的模型)""" - sp_no: str - sp_name: str - template_id: str - apply_time: int - applyer_userid: str - sp_status: int # 1-审批中 2-已通过 3-已驳回 4-已撤销 6-通过后撤销 7-已删除 10-已支付 - status_change_event: int # 1-提单 2-同意 3-驳回 4-转审 5-催办 6-撤销 8-通过后撤销 10-添加备注 - - -class ApprovalUrgeRequest(BaseModel): - """审批推送请求 — 手动触发资产年限核查+推送""" - sp_no: str - - -class ApprovalDetectIntentRequest(BaseModel): - """审批意图检测请求""" - text: str - employee_id: Optional[str] = None - - -class ApprovalDetectIntentResponse(BaseModel): - """审批意图检测响应 - - Attributes: - is_approval_request: 是否为审批请求(原字段,语义不变) - confidence: 置信度(0.0~1.0)(原字段,语义不变) - approval_type: 审批类型(原字段,语义不变) - source: 结果来源 — dify(Dify识别) / keyword_prefilter(关键词预过滤未命中) / fallback(降级兜底) - intent_type: 意图大类(新增)— approval/it_consult/non_it_routing/chitchat - business_category: 非IT业务类别(新增)— 行政/人力资源/财务/法务/行政-物业,仅 non_it_routing 时有值 - routing_confidence: 路由置信度(新增)— 0.0~1.0,≥0.7 触发名片推荐 - """ - is_approval_request: bool - confidence: float - approval_type: Optional[str] = None - source: str # "dify" | "keyword_prefilter" | "fallback" - # 以下为 v3 统一意图识别新增字段(向后兼容:原审批逻辑只读取前4个字段) - intent_type: str = "chitchat" - business_category: Optional[str] = None - routing_confidence: float = 0.0 - - -# ============================================================================= -# 企微API调用辅助函数 -# ============================================================================= - -async def get_approval_token(redis: aioredis.Redis) -> str: - """获取企微access_token(使用IT支持应用Secret,IP已在白名单中)。""" - manager = TokenManager(redis) - try: - return await manager.get_token() - finally: - await manager.close() - - -async def get_template_detail(access_token: str, template_id: str) -> dict: - """获取审批模板详情 - - 对应企微API: - POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail - - 返回模板内的控件构成及控件ID - """ - url = "https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail" - params = {"access_token": access_token} - - async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client: - response = await client.post(url, params=params, json={"template_id": template_id}) - result = response.json() - - if result.get("errcode") != 0: - logger.error(f"获取模板详情失败: {result.get('errmsg')}") - raise Exception(f"获取模板详情失败: {result.get('errmsg')}") - - return result - - -async def submit_approval_api( - access_token: str, - template_id: str, - creator_userid: str, - contents: list[dict], - use_template_approver: int = 1 -) -> dict: - """提交审批申请 - - 对应企微API: - POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent - - Args: - access_token: 审批应用access_token - template_id: 模板ID - creator_userid: 申请人userid - contents: 表单控件内容列表 - use_template_approver: 1-使用模板预设流程 - - Returns: - {"sp_no": "审批单号"} - """ - url = "https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent" - params = {"access_token": access_token} - - payload = { - "creator_userid": creator_userid, - "template_id": template_id, - "use_template_approver": use_template_approver, - "apply_data": { - "contents": contents - } - } - - async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client: - response = await client.post(url, params=params, json=payload) - result = response.json() - - if result.get("errcode") != 0: - logger.error(f"提交审批失败: {result.get('errmsg')}") - raise Exception(f"提交审批失败: {result.get('errmsg')}") - - return {"sp_no": result.get("sp_no")} - - -# ============================================================================= -# 资产升级审批推送 — 辅助函数 -# ============================================================================= - -async def get_approval_detail(access_token: str, sp_no: str) -> dict: - """获取审批申请详情。 - - 对应企微API: - POST https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail - - 返回审批单的表单数据、审批流程节点、当前审批人等信息。 - - Args: - access_token: 审批应用 access_token - sp_no: 审批单号 - - Returns: - dict: 企微API返回的完整审批详情 - - Raises: - Exception: API调用失败或返回错误 - """ - url = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail" - params = {"access_token": access_token} - payload = {"sp_no": sp_no} - - async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client: - response = await client.post(url, params=params, json=payload) - result = response.json() - - if result.get("errcode") != 0: - logger.error(f"获取审批详情失败: sp_no={sp_no}, errmsg={result.get('errmsg')}") - raise Exception(f"获取审批详情失败: {result.get('errmsg')}") - - return result - - -async def get_approval_data( - access_token: str, - starttime: int, - endtime: int, - filters: Optional[list] = None, - cursor: int = 0, - size: int = 100, -) -> dict: - """获取审批申请数据列表。 - - 对应企微API: - POST https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldata - - 按时间范围和过滤条件查询审批单列表,返回审批单号及基本信息。 - 支持 cursor 分页。 - - Args: - access_token: 审批应用 access_token - starttime: 查询开始时间(秒级 Unix 时间戳) - endtime: 查询结束时间(秒级 Unix 时间戳) - filters: 过滤条件列表,格式 [{"key": "template_id", "value": "xxx"}, ...] - cursor: 分页游标(从 0 开始) - size: 单页返回数量(最大 100) - - Returns: - dict: 企微API返回的完整响应,包含 data(审批列表)、total、next_cursor - - Raises: - Exception: API调用失败或返回错误 - """ - url = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldata" - params = {"access_token": access_token} - payload = { - "starttime": starttime, - "endtime": endtime, - "cursor": cursor, - "size": size, - } - if filters: - payload["filters"] = filters - - async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)) as client: - response = await client.post(url, params=params, json=payload) - result = response.json() - - if result.get("errcode") != 0: - logger.error( - f"getapprovaldata 调用失败: errcode={result.get('errcode')}, " - f"errmsg={result.get('errmsg')}" - ) - raise Exception(f"获取审批数据列表失败: {result.get('errmsg')}") - - return result - - -def _extract_asset_code(approval_detail: dict) -> Optional[str]: - """从审批单表单数据中提取资产编号。 - - 遍历 apply_data.contents,查找标题包含"资产编号"/"固定资产编号"等关键字的控件, - 提取其值。支持 Text/Textarea(value.text)和 Selector(value.value)两种控件类型。 - - Args: - approval_detail: get_approval_detail 返回的完整审批详情 - - Returns: - str: 资产编号,提取失败返回 None - """ - try: - contents = ( - approval_detail - .get("info", {}) - .get("apply_data", {}) - .get("contents", []) - ) - # 资产编号可能的标题名 - asset_code_titles = {"资产编号", "固定资产编号", "资产编码", "设备编号"} - - for item in contents: - # 检查控件标题 - title_list = item.get("title", []) - for title_obj in title_list: - title_text = title_obj.get("text", "") - if title_text in asset_code_titles: - # 提取控件值 - value = item.get("value", {}) - # Text / Textarea 类型:value.text - text = value.get("text", "") - if text: - return text.strip() - # Selector 类型:value.value - sel_value = value.get("value", "") - if sel_value: - return sel_value.strip() - except Exception as e: - logger.error(f"提取资产编号失败: {e}") - return None - - -def _extract_current_approver(approval_detail: dict) -> Optional[str]: - """从审批单详情中提取当前审批人 userid。 - - 遍历 sp_record,查找 sp_status=1(审批中)的节点, - 返回其 details 中第一个 sp_status=1(待处理)的 approver.userid。 - - 注意:企微 API 实际返回的字段名: - - sp_record[].sp_status(非 status) - - sp_record[].approverattr(非 approverAttr) - - sp_record[].details[].approver.userid(非 sp_record[].approver[].userid) - - Args: - approval_detail: get_approval_detail 返回的完整审批详情 - - Returns: - str: 当前审批人 userid,提取失败返回 None - """ - try: - sp_record = approval_detail.get("info", {}).get("sp_record", []) - for record in sp_record: - # 企微 API 字段名为 sp_status(非 status),1=审批中 - if record.get("sp_status") != 1: - continue - - # 审批人信息在 details[].approver 中(非 record.approver) - details = record.get("details", []) - for detail in details: - # detail.sp_status=1 表示该审批人尚未处理 - if detail.get("sp_status") == 1: - approver = detail.get("approver", {}) - if isinstance(approver, dict): - userid = approver.get("userid", "") - if userid: - return userid - elif isinstance(approver, list) and len(approver) > 0: - userid = approver[0].get("userid", "") - if userid: - return userid - except Exception as e: - logger.error(f"提取当前审批人失败: {e}") - return None - - -def _build_urge_description(check_result: dict, applyer_userid: str) -> str: - """构建推送卡片的描述文本。 - - 根据资产核查结果拼接多行描述,包含资产编号、设备名称、启用日期、 - 已使用年限、核查结论、申请人等信息。 - - Args: - check_result: AssetService.check_device_age 返回的核查结果 - applyer_userid: 申请人 userid - - Returns: - str: 多行描述文本 - """ - lines = [] - - # 资产编号 - lines.append(f"资产编号:{check_result.get('asset_code', '未知')}") - - # 设备名称 - if check_result.get("asset_name"): - lines.append(f"设备名称:{check_result['asset_name']}") - - # 启用日期 - if check_result.get("start_date"): - lines.append(f"启用日期:{check_result['start_date']}") - - # 已使用年限 - if check_result.get("years_display"): - lines.append(f"已使用:{check_result['years_display']}") - - # 核查结论 - threshold = settings.asset_replacement_threshold_years - if check_result.get("found") and check_result.get("meets_threshold") is not None: - if check_result["meets_threshold"]: - lines.append(f"核查结论:✅ 已满{threshold}年,符合更换条件") - else: - lines.append(f"核查结论:❌ 未满{threshold}年,不符合更换条件") - elif not check_result.get("found"): - lines.append("核查结论:⚠️ 未找到资产记录,请人工核查") - else: - lines.append("核查结论:⚠️ 无法计算年限,请人工核查") - - # 申请人 - lines.append(f"申请人:{applyer_userid}") - - # 提示 - lines.append("请点击查看审批详情") - - return "\n".join(lines) - - -async def _do_asset_urge(sp_no: str, redis: aioredis.Redis) -> dict: - """执行资产年限核查 + 向审批人推送卡片消息的完整逻辑。 - - 流程: - 1. 获取审批应用 access_token - 2. 调用 oa/getapprovaldetail 获取审批单详情 - 3. 从表单数据提取资产编号 - 4. 从审批流程提取当前审批人 userid - 5. 用 AssetService 核查设备使用年限 - 6. 向当前审批人推送企微文本卡片消息 - - 所有步骤均有 try/except 保护,失败时记日志但不抛出异常。 - - Args: - sp_no: 审批单号 - redis: Redis 客户端(用于获取 access_token) - - Returns: - dict: 执行结果,包含 success/message 以及核查详情 - """ - try: - # 1. 获取审批应用 access_token - token = await get_approval_token(redis) - - # 2. 获取审批单详情 - detail = await get_approval_detail(token, sp_no) - - # 3. 提取资产编号 - asset_code = _extract_asset_code(detail) - if not asset_code: - logger.warning(f"审批单 {sp_no} 未能提取资产编号") - return {"success": False, "message": "未能从审批表单提取资产编号", "sp_no": sp_no} - - # 4. 提取当前审批人 - approver = _extract_current_approver(detail) - if not approver: - logger.warning(f"审批单 {sp_no} 未能提取当前审批人") - return {"success": False, "message": "未能提取当前审批人 userid", "sp_no": sp_no} - - # 5. 提取申请人 userid - applyer_userid = ( - detail.get("info", {}).get("applyer", {}).get("userid", "") - ) - - # 6. 核查资产年限 - asset_service = AssetService() - threshold = settings.asset_replacement_threshold_years - check_result = asset_service.check_device_age(asset_code, threshold) - - # 7. 构建并推送卡片消息 - description = _build_urge_description(check_result, applyer_userid) - card_url = ( - f"https://app.work.weixin.qq.com/wework_admin/approval_v3" - f"#/?template_id={ASSET_UPGRADE_TEMPLATE_ID}&sp_id={sp_no}" - ) - - # 使用 IT 支持应用的 access_token 发送消息(非审批应用 token) - wecom_service = WecomService(redis_client=redis) - await wecom_service.send_card_message( - user_id=approver, - title="IT资产升级审批 — 设备年限核查", - description=description, - url=card_url, - btntxt="去审批", - ) - - logger.info(f"资产年限核查推送成功: sp_no={sp_no}, approver={approver}") - return { - "success": True, - "message": "推送成功", - "sp_no": sp_no, - "approver": approver, - "asset_code": asset_code, - "check_result": check_result, - } - - except Exception as e: - logger.error(f"资产年限核查推送失败: sp_no={sp_no}, error={e}") - return {"success": False, "message": f"推送失败: {e}", "sp_no": sp_no} - - -# ============================================================================= -# API 端点 -# ============================================================================= - -@router.get("/approval/templates", response_model=list[ApprovalTemplateResponse]) -async def get_approval_templates(): - """获取所有审批模板列表""" - return list(APPROVAL_TEMPLATES.values()) - - -@router.get("/approval/templates/{template_id}", response_model=ApprovalTemplateResponse) -async def get_approval_template(template_id: str): - """获取指定审批模板详情""" - if template_id not in APPROVAL_TEMPLATES: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="模板不存在") - return APPROVAL_TEMPLATES[template_id] - - -@router.get("/approval/templates/{template_id}/detail") -async def get_template_full_detail( - template_id: str, - redis: aioredis.Redis = Depends(get_redis) -): - """获取审批模板完整详情(控件结构)""" - if template_id not in APPROVAL_TEMPLATES: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="模板不存在") - - try: - token = await get_approval_token(redis) - detail = await get_template_detail(token, template_id) - return detail - except Exception as e: - from fastapi import HTTPException - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/approval/jump") -async def create_approval_jump(request: ApprovalJumpRequest): - """生成跳转审批链接""" - template = APPROVAL_TEMPLATES.get(request.template_id) - if not template: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="模板不存在") - - if template["type"] != "jump": - from fastapi import HTTPException - raise HTTPException(status_code=400, detail="该模板不支持跳转方式") - - # 生成跳转URL(企微审批链接格式) - jump_url = f"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=TOKEN&template_id={request.template_id}" - - return success_response(data=ApprovalJumpResponse( - url=jump_url, - template_name=template["name"], - )) - - -@router.post("/approval/submit", response_model=ApprovalSubmitResponse) -async def submit_approval( - request: ApprovalSubmitRequest, - redis: aioredis.Redis = Depends(get_redis) -): - """API提交审批申请""" - template = APPROVAL_TEMPLATES.get(request.template_id) - if not template: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="模板不存在") - - if template["type"] != "api": - from fastapi import HTTPException - raise HTTPException(status_code=400, detail="该模板不支持API提交") - - try: - # 1. 获取审批token - token = await get_approval_token(redis) - - # 2. 转换contents格式 - contents = [item.model_dump() for item in request.contents] - - # 3. 提交审批 - result = await submit_approval_api( - access_token=token, - template_id=request.template_id, - creator_userid=request.employee_id, - contents=contents, - use_template_approver=request.use_template_approver - ) - - return ApprovalSubmitResponse( - sp_no=result["sp_no"], - template_name=template["name"], - ) - - except Exception as e: - from fastapi import HTTPException - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/approval/callback") -async def approval_callback( - sp_no: str = Query(...), - sp_name: str = Query(...), - template_id: str = Query(...), - apply_time: int = Query(...), - applyer_userid: str = Query(...), - sp_status: int = Query(...), - status_change_event: int = Query(...), - redis: aioredis.Redis = Depends(get_redis), -): - """审批状态变化回调处理 - - 对应企微审批回调事件: sys_approval_change - - 状态变化类型 (status_change_event): - 1 - 提单 - 2 - 同意 - 3 - 驳回 - 4 - 转审 - 5 - 催办 - 6 - 撤销 - 8 - 通过后撤销 - 10 - 添加备注 - - 审批单状态 (sp_status): - 1 - 审批中 - 2 - 已通过 - 3 - 已驳回 - 4 - 已撤销 - 6 - 通过后撤销 - 7 - 已删除 - 10 - 已支付 - """ - logger.info(f"审批回调: sp_no={sp_no}, status={sp_status}, event={status_change_event}") - - # TODO: 根据业务需求处理审批状态变化 - # 例如: - # - 审批通过后,更新IT服务台待办状态 - # - 审批驳回后,通知申请人 - # - 审批撤销后,关闭相关工单 - - event_map = { - 1: "submitted", - 2: "approved", - 3: "rejected", - 4: "transferred", - 5: "reminded", - 6: "revoked", - 8: "revoked_after_approved", - 10: "commented" - } - - event_type = event_map.get(status_change_event, f"unknown_{status_change_event}") - logger.info(f"审批事件类型: {event_type}") - - # IT资产升级申请提单时,自动触发年限核查+推送(异步执行,不阻塞回调响应) - if status_change_event == 1 and template_id == ASSET_UPGRADE_TEMPLATE_ID: - logger.info(f"检测到IT资产升级申请提单: sp_no={sp_no}") - asyncio.create_task(_do_asset_urge(sp_no, redis)) - - return {"errcode": 0, "errmsg": "ok"} - - -@router.post("/approval/urge") -async def urge_approval( - request: ApprovalUrgeRequest, - redis: aioredis.Redis = Depends(get_redis), -): - """手动触发审批推送 — 资产年限核查 + 向当前审批人推送卡片消息。 - - 用于回调未触发或需重新推送的场景。内部调用 _do_asset_urge 执行完整逻辑。 - - Args: - request: 包含 sp_no(审批单号) - redis: Redis 客户端 - - Returns: - success_response: 包含推送结果和核查详情 - """ - result = await _do_asset_urge(request.sp_no, redis) - return success_response(data=result) - - -@router.get("/approval/keywords") -async def get_approval_keywords(): - """获取所有审批关键词(用于前端关键词检测)""" - keywords = [] - for template in APPROVAL_TEMPLATES.values(): - for kw in template["keywords"]: - keywords.append({ - "keyword": kw, - "template_id": template["id"], - "template_name": template["name"], - "type": template["type"], - }) - return success_response(data=keywords) - - -# ============================================================================= -# 审批意图识别(Dify + 关键词预过滤 + 降级兜底) -# ============================================================================= - -def _keyword_prefilter(text: str) -> bool: - """关键词预过滤:检查文本是否包含审批相关关键词(v2.0 收窄版)。 - - v2.0 变更(2026-07-13): - - 不再合并 APPROVAL_TEMPLATES 的 keywords(包含"借用""升级""外联"等泛化词) - - 仅使用 APPROVAL_PREFILTER_KEYWORDS(强意图词 + 复合专有词) - - 模板 keywords 仍保留在 KEYWORD_TO_APPROVAL_TYPE 中,仅用于 Dify 不可用时的降级兜底 - - Args: - text: 用户消息文本 - - Returns: - bool: 是否包含审批关键词 - """ - if not text: - return False - lower_text = text.lower() - # v2.0: 仅使用预过滤关键词列表,不合并模板 keywords - return any(kw.lower() in lower_text for kw in APPROVAL_PREFILTER_KEYWORDS) - - -def _fallback_detect(text: str) -> tuple[bool, float, Optional[str]]: - """关键词兜底:Dify 不可用时通过关键词匹配判断审批意图。 - - 遍历 KEYWORD_TO_APPROVAL_TYPE 映射,命中第一个关键词即返回对应审批类型。 - 置信度取 0.6(略低于阈值,但预过滤已通过说明有审批关键词)。 - - Args: - text: 用户消息文本 - - Returns: - tuple: (is_approval_request, confidence, approval_type) - """ - lower_text = (text or "").lower() - approval_type: Optional[str] = None - for kw, atype in KEYWORD_TO_APPROVAL_TYPE.items(): - if kw.lower() in lower_text: - approval_type = atype - break - # 预过滤已通过(说明有审批关键词),兜底返回 is_approval_request=True - return True, 0.6, approval_type - - -async def _call_dify_approval_intent(text: str, employee_id: str = "") -> dict: - """调用 Dify 审批意图识别应用(Dify 原生 API)。 - - 直接调用 Dify 原生 /v1/chat-messages 接口,绕过 Dify2OpenAI 代理。 - Dify2OpenAI 代理会将 JSON 响应序列化为 "[object Object]" 字符串, - 导致后端无法解析。使用原生 API 可获得正确的 JSON 响应。 - - Dify 应用的 System Prompt 已在 Dify 后台配置好,后端只需把用户消息传过去。 - 返回 JSON: {"is_approval_request": bool, "confidence": float, "approval_type": str|null} - - Args: - text: 用户消息文本 - employee_id: 员工 ID(可选,传给 Dify 的 user 字段) - - Returns: - dict: {"is_approval_request": bool, "confidence": float, "approval_type": str|None} - - Raises: - Exception: Dify 调用失败或响应解析失败 - """ - base_url = settings.approval_dify_base_url - api_key = settings.approval_dify_api_key - timeout = settings.approval_dify_timeout - - if not base_url or not api_key: - raise ValueError("Dify 审批意图识别应用未配置(APPROVAL_DIFY_BASE_URL / APPROVAL_DIFY_API_KEY)") - - # 构建请求 URL:base_url + /v1/chat-messages(Dify 原生 API) - url = f"{base_url.rstrip('/')}/v1/chat-messages" - - body = { - "inputs": {}, # Dify 应用的输入变量(无自定义变量时为空) - "query": text, # 用户消息文本 - "response_mode": "blocking", # 阻塞模式,等待完整响应 - "user": employee_id or "approval_detection", # 用户标识 - } - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - - async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client: - response = await client.post(url, json=body, headers=headers) - response.raise_for_status() - data = response.json() - - # 解析 Dify 原生响应:answer 字段包含 AI 返回的文本(JSON 字符串) - answer = data.get("answer", "") - parsed = json.loads(answer) - - return { - "is_approval_request": bool(parsed.get("is_approval_request", False)), - "confidence": float(parsed.get("confidence", 0.0)), - "approval_type": parsed.get("approval_type"), - # v3 统一意图识别新增字段(向后兼容:旧 Prompt 无这些字段时取默认值) - "intent_type": str(parsed.get("intent_type", "chitchat")), - "business_category": parsed.get("business_category"), - "routing_confidence": float(parsed.get("routing_confidence", 0.0)), - } - - -@router.post("/approval/detect-intent") -async def detect_approval_intent(request: ApprovalDetectIntentRequest): - """审批意图检测端点。 - - 流程: - 1. 关键词预过滤 — 未命中直接返回 false(避免每条消息都调 Dify) - 2. 命中关键词 → 调用 Dify 审批意图识别应用 - 3. Dify 调用失败 → 降级为关键词匹配(兜底) - - Args: - request: 包含 text(用户消息)和可选的 employee_id - - Returns: - ApprovalDetectIntentResponse: 检测结果 - """ - text = request.text or "" - - # 1. 关键词预过滤 - if not _keyword_prefilter(text): - return success_response(data=ApprovalDetectIntentResponse( - is_approval_request=False, - confidence=0.0, - approval_type=None, - source="keyword_prefilter", - intent_type="chitchat", - business_category=None, - routing_confidence=0.0, - )) - - # 2. 调用 Dify 意图识别 - try: - result = await _call_dify_approval_intent(text, request.employee_id or "") - # 检查置信度阈值 - threshold = settings.approval_confidence_threshold - is_approval = result["is_approval_request"] and result["confidence"] >= threshold - logger.info( - f"审批意图检测(Dify): is_approval={is_approval}, " - f"confidence={result['confidence']}, type={result.get('approval_type')}, " - f"intent_type={result.get('intent_type')}, " - f"business_category={result.get('business_category')}, " - f"routing_confidence={result.get('routing_confidence')}" - ) - return success_response(data=ApprovalDetectIntentResponse( - is_approval_request=is_approval, - confidence=result["confidence"], - approval_type=result.get("approval_type"), - source="dify", - intent_type=result.get("intent_type", "chitchat"), - business_category=result.get("business_category"), - routing_confidence=result.get("routing_confidence", 0.0), - )) - except Exception as e: - logger.warning(f"Dify 审批意图识别失败,降级为关键词匹配: {e}") - # 3. 降级为关键词匹配 - is_approval, confidence, approval_type = _fallback_detect(text) - logger.info( - f"审批意图检测(兜底): is_approval={is_approval}, " - f"confidence={confidence}, type={approval_type}" - ) - return success_response(data=ApprovalDetectIntentResponse( - is_approval_request=is_approval, - confidence=confidence, - approval_type=approval_type, - source="fallback", - intent_type="approval" if is_approval else "chitchat", - business_category=None, - routing_confidence=0.0, - )) diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index d069026..3afc962 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -231,102 +231,9 @@ class AIService: # -------------------------------------------------------------------------- # 流式调用:SSE 流式返回(供 WebSocket 推送给前端) # -------------------------------------------------------------------------- - async def get_reply_stream( - self, - message: str, - conversation_id: Optional[str] = None, - user_id: Optional[str] = None, - ) -> AsyncGenerator[Dict[str, Any], None]: - """调用 Dify API 获取流式 AI 回复(SSE),逐块 yield 给调用方。 - - Yields: - Dict: {"delta": str, "finished": bool, "conversation_id": str, "hit": bool|None} - - 流式中间块:{"delta": 增量, "finished": False, "hit": None} - - 终态块:{"delta": "", "finished": True, "hit": 命中判断} - - 实现: - - stream=True 走 SSE,解析 data: {...} 行,逐块 yield delta - - 流结束后用完整内容整体判断 hit(_check_knowledge_hit) - 容错:若 Dify 不支持流式 / 超时 / 非 SSE 格式,catch 后 fallback 到 - get_reply 非流式,yield 一次完整内容(前端退化为"整段到达", - 功能不破,仅无逐字动画)。 - """ - 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, - } + # v4.0 批次4:get_reply_stream 已删除(~96 行) + # v2.0 起 AI 回复改为 blocking + JSON 结构化(get_structured_reply), + # 流式 SSE 路径零调用,属死代码。 # -------------------------------------------------------------------------- # 结构化调用:blocking 模式,返回解析后的 JSON {text, action, options} diff --git a/backend/app/tasks/scheduler.py b/backend/app/tasks/scheduler.py deleted file mode 100644 index c11105d..0000000 --- a/backend/app/tasks/scheduler.py +++ /dev/null @@ -1,149 +0,0 @@ -# -*- coding: utf-8 -*- -""" -资产推荐定时任务 - -功能: -1. 每日运维提醒推送(L2) -2. 画像缓存预热 -3. 新员工欢迎推送(L3) -""" - -import asyncio -import logging -from datetime import datetime - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.cron import CronTrigger - -logger = logging.getLogger(__name__) - -# 定时任务调度器实例 -# v4.0 P1-7 修复:AsyncIOSScheduler → AsyncIOScheduler(原拼写错误,import 即 NameError) -# 注意:本模块当前无人 import(main.py 使用自己的 _scheduler),批次 4 候选删除 -scheduler = AsyncIOScheduler() - - -def setup_scheduled_tasks(): - """配置定时任务""" - - # 每日 9:00 运维提醒 - scheduler.add_job( - daily_maintenance_push, - trigger=CronTrigger(hour=9, minute=0), - id='daily_maintenance_push', - name='每日运维提醒推送', - replace_existing=True - ) - - # 每小时画像缓存刷新 - scheduler.add_job( - hourly_profile_sync, - trigger=CronTrigger(minute=0), - id='hourly_profile_sync', - name='每小时员工画像同步', - replace_existing=True - ) - - # 每天 8:55 检查新员工 - scheduler.add_job( - check_new_employees, - trigger=CronTrigger(hour=8, minute=55), - id='check_new_employees', - name='新员工欢迎检查', - replace_existing=True - ) - - logger.info(f"[Scheduler] 已配置 {len(scheduler.get_jobs())} 个定时任务") - - -async def daily_maintenance_push(): - """每日运维提醒推送""" - - logger.info("[Scheduler] 开始执行每日运维提醒推送") - - from app.services.asset_recommend_service import get_asset_recommend_service - from app.services.employee_profile_service import get_employee_profile_service - - asset_service = get_asset_recommend_service() - profile_service = get_employee_profile_service() - - # 获取全部员工(分页) - # TODO: 实现分页获取员工列表 - # page = 1 - # while True: - # employees = await get_employees_paginated(page, 100) - # if not employees: - # break - # - # for emp in employees: - # try: - # profile = await profile_service.get_profile(emp.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) - # - # if l2_recs: - # ws_msg = asset_service.build_ws_message(l2_recs) - # from app.services.ws_manager import manager as ws_manager - # await ws_manager.broadcast_to_employees([emp.id], ws_msg) - # - # except Exception as e: - # logger.error(f"[Scheduler] 推送失败: {emp.id}, {e}") - # - # page += 1 - - logger.info("[Scheduler] 每日运维提醒推送完成 (TODO: 实现员工列表获取)") - - -async def hourly_profile_sync(): - """每小时同步员工画像缓存""" - - logger.info("[Scheduler] 开始同步员工画像缓存") - - try: - profile_service = get_employee_profile_service() - deleted = await profile_service.clear_expired_cache() - logger.info(f"[Scheduler] 画像缓存同步完成,清理 {deleted} 条") - except Exception as e: - logger.error(f"[Scheduler] 画像缓存同步失败: {e}") - - -async def check_new_employees(): - """检查新员工并发送欢迎""" - - logger.info("[Scheduler] 检查新员工") - - # TODO: 实现新员工检测逻辑 - # 获取过去 24 小时入职的员工 - # new_employees = await get_new_employees(days=1) - # - # for emp in new_employees: - # asset_service = get_asset_recommend_service() - # role_recs = asset_service.get_by_role('new_employee') - # - # if role_recs: - # ws_msg = asset_service.build_ws_message(role_recs) - # from app.services.ws_manager import manager as ws_manager - # await ws_manager.broadcast_to_employees([emp.id], ws_msg) - - logger.info("[Scheduler] 新员工检查完成 (TODO: 实现)") - - -def start_scheduler(): - """启动定时任务调度器""" - if not scheduler.running: - setup_scheduled_tasks() - scheduler.start() - logger.info("定时任务调度器已启动") - - -def stop_scheduler(): - """停止定时任务调度器""" - if scheduler.running: - scheduler.shutdown() - logger.info("定时任务调度器已停止") diff --git a/frontend-h5/components.d.ts b/frontend-h5/components.d.ts index 984d86c..6ace64d 100644 --- a/frontend-h5/components.d.ts +++ b/frontend-h5/components.d.ts @@ -24,11 +24,9 @@ declare module 'vue' { EvaluationDialog: typeof import('./src/components/chat/EvaluationDialog.vue')['default'] ImageUploader: typeof import('./src/components/ImageUploader.vue')['default'] InputBar: typeof import('./src/components/chat/InputBar.vue')['default'] - InputBox: typeof import('./src/components/chat/InputBox.vue')['default'] InviteParticipantSheet: typeof import('./src/components/chat/InviteParticipantSheet.vue')['default'] ITHealthDashboard: typeof import('./src/components/assistant/ITHealthDashboard.vue')['default'] MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default'] - MessageItem: typeof import('./src/components/chat/MessageItem.vue')['default'] ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default'] ParticipantStrip: typeof import('./src/components/chat/ParticipantStrip.vue')['default'] QueueWaiting: typeof import('./src/components/assistant/QueueWaiting.vue')['default'] @@ -45,7 +43,6 @@ declare module 'vue' { SoftwareAndApply: typeof import('./src/components/assistant/SoftwareAndApply.vue')['default'] SoftwareDownloads: typeof import('./src/components/assistant/SoftwareDownloads.vue')['default'] SoftwareInstall: typeof import('./src/components/assistant/SoftwareInstall.vue')['default'] - TriageCard: typeof import('./src/components/TriageCard.vue')['default'] TroubleshootFlow: typeof import('./src/components/chat/TroubleshootFlow.vue')['default'] TroubleshootProgress: typeof import('./src/components/chat/TroubleshootProgress.vue')['default'] UndoButton: typeof import('./src/components/automation/UndoButton.vue')['default'] diff --git a/frontend-h5/src/api/triage.ts b/frontend-h5/src/api/triage.ts deleted file mode 100644 index b688b47..0000000 --- a/frontend-h5/src/api/triage.ts +++ /dev/null @@ -1,108 +0,0 @@ -// ============================================================================= -// 企微IT智能服务台 — H5 端分诊 API 封装 -// ============================================================================= -// 说明:封装 H5 端 5 个分诊交互 API 调用 -// ============================================================================= - -import request from './index' - -/** 分诊选项 */ -export interface TriageOption { - label: string - probability?: number -} - -/** 分诊步骤 */ -export interface TriageStep { - question: string - options: TriageOption[] -} - -/** 发起分诊请求参数 */ -export interface TriageStartParams { - conversation_id: string - question: string -} - -/** 发起分诊响应 */ -export interface TriageStartResult { - triage_id: string - steps: TriageStep[] - total: number - confidence?: number - urgency: string - suggested_route?: string - status?: string - message?: string -} - -/** 提交步骤响应 */ -export interface TriageStepResult { - next_step: TriageStep | null - collected_context: string[] -} - -/** 转人工响应 */ -export interface TriageTransferResult { - conversation_id: string - status: string -} - -/** 分诊完成响应 */ -export interface TriageCompleteResult { - reply: string - confidence: number -} - -/** - * 发起分诊 - * POST /api/h5/triage/start - */ -export function startTriage(params: TriageStartParams): Promise { - return request.post('/h5/triage/start', params) -} - -/** - * 提交步骤选择 - * POST /api/h5/triage/step - */ -export function submitTriageStep( - triage_id: string, - step_index: number, - selected_label: string, -): Promise { - return request.post('/h5/triage/step', { triage_id, step_index, selected_label }) -} - -/** - * 跳过步骤 - * POST /api/h5/triage/skip - */ -export function skipTriageStep( - triage_id: string, - step_index: number, -): Promise<{ next_step: TriageStep | null }> { - return request.post('/h5/triage/skip', { triage_id, step_index }) -} - -/** - * 转人工 - * POST /api/h5/triage/transfer - */ -export function transferTriageToHuman( - triage_id: string, - context: string[], -): Promise { - return request.post('/h5/triage/transfer', { triage_id, context }) -} - -/** - * 分诊完成 - * POST /api/h5/triage/complete - */ -export function completeTriage( - triage_id: string, - context: string[], -): Promise { - return request.post('/h5/triage/complete', { triage_id, context }) -} diff --git a/frontend-h5/src/components/TriageCard.vue b/frontend-h5/src/components/TriageCard.vue deleted file mode 100644 index ee72270..0000000 --- a/frontend-h5/src/components/TriageCard.vue +++ /dev/null @@ -1,649 +0,0 @@ - - - - - - - diff --git a/frontend-h5/src/components/assistant/RecommendCard.vue.bak_bridge_1784261164 b/frontend-h5/src/components/assistant/RecommendCard.vue.bak_bridge_1784261164 deleted file mode 100644 index 5fcff66..0000000 --- a/frontend-h5/src/components/assistant/RecommendCard.vue.bak_bridge_1784261164 +++ /dev/null @@ -1,434 +0,0 @@ - - - - - - - diff --git a/frontend-h5/src/components/chat/ApprovalCardModal.vue.bak_bridge_1784261164 b/frontend-h5/src/components/chat/ApprovalCardModal.vue.bak_bridge_1784261164 deleted file mode 100644 index a7c3acb..0000000 --- a/frontend-h5/src/components/chat/ApprovalCardModal.vue.bak_bridge_1784261164 +++ /dev/null @@ -1,339 +0,0 @@ - - - - - - - diff --git a/frontend-h5/src/composables/useTriage.ts b/frontend-h5/src/composables/useTriage.ts deleted file mode 100644 index aea24cf..0000000 --- a/frontend-h5/src/composables/useTriage.ts +++ /dev/null @@ -1,318 +0,0 @@ -// ============================================================================= -// 企微IT智能服务台 — H5 端分诊状态管理 Composable -// ============================================================================= -// 说明:管理分诊交互的完整状态,封装所有分诊 API 调用。 -// 状态:当前步骤、已收集上下文、分诊ID、分诊步骤数据等 -// 方法:startTriage, submitStep, skipStep, completeTriage, transferToHuman -// ============================================================================= - -import { ref, computed, readonly } from 'vue' -import { - startTriage, - submitTriageStep, - skipTriageStep, - transferTriageToHuman, - completeTriage, - type TriageStep, - type TriageStartResult, -} from '@/api/triage' - -/** 分诊状态 */ -export type TriageStatus = 'idle' | 'loading' | 'triaging' | 'completed' | 'transferred' | 'timeout' | 'error' - -/** useTriage composable — 分诊状态管理 */ -export function useTriage() { - // ========================================================================== - // 响应式状态 - // ========================================================================== - - /** 分诊会话ID */ - const triageId = ref('') - - /** 当前步骤序号(0-based) */ - const currentStepIndex = ref(0) - - /** 所有分诊步骤数据 */ - const triageSteps = ref([]) - - /** 总步骤数 */ - const totalSteps = ref(0) - - /** 已收集的上下文 */ - const collectedContext = ref([]) - - /** AI 置信度 */ - const confidence = ref(null) - - /** 紧急度 */ - const urgency = ref('medium') - - /** AI 建议路由 */ - const suggestedRoute = ref(null) - - /** 分诊状态 */ - const status = ref('idle') - - /** 错误消息 */ - const errorMessage = ref('') - - /** 最终 AI 回复 */ - const finalReply = ref('') - - /** 是否超时自动转人工 */ - const isTimeout = ref(false) - - /** 被坐席排除的选项标签 */ - const excludedLabels = ref([]) - - /** 坐席推荐的选项标签 */ - const recommendedLabel = ref('') - - // ========================================================================== - // 计算属性 - // ========================================================================== - - /** 当前步骤数据 */ - const currentStep = computed(() => { - if (currentStepIndex.value < triageSteps.value.length) { - return triageSteps.value[currentStepIndex.value] - } - return null - }) - - /** 当前步骤序号(1-based,展示用) */ - const currentStepNumber = computed(() => currentStepIndex.value + 1) - - /** 是否为最后一步 */ - const isLastStep = computed(() => currentStepNumber.value >= totalSteps.value) - - /** 是否有下一步 */ - const hasNextStep = computed(() => currentStepIndex.value < triageSteps.value.length - 1) - - /** 是否正在进行分诊 */ - const isTriaging = computed(() => status.value === 'triaging') - - /** 是否加载中 */ - const isLoading = computed(() => status.value === 'loading') - - // ========================================================================== - // 方法 - // ========================================================================== - - /** - * 发起分诊 - * @param conversationId 会话ID - * @param question 员工问题文本 - * @returns 是否成功 - */ - async function startTriageFlow(conversationId: string, question: string): Promise { - status.value = 'loading' - errorMessage.value = '' - isTimeout.value = false - - try { - const result: TriageStartResult = await startTriage({ - conversation_id: conversationId, - question, - }) - - // 超时自动转人工 - if (result.status === 'timeout') { - status.value = 'timeout' - isTimeout.value = true - errorMessage.value = result.message || '分诊超时,已自动转人工' - return false - } - - triageId.value = result.triage_id - triageSteps.value = result.steps || [] - totalSteps.value = result.total || result.steps.length - confidence.value = result.confidence ?? null - urgency.value = result.urgency || 'medium' - suggestedRoute.value = result.suggested_route || null - currentStepIndex.value = 0 - collectedContext.value = [] - status.value = 'triaging' - - return true - } catch (e: any) { - status.value = 'error' - errorMessage.value = e?.message || '发起分诊失败' - return false - } - } - - /** - * 提交步骤选择 - * @param selectedLabel 选择的选项标签 - * @returns 是否还有下一步 - */ - async function submitStep(selectedLabel: string): Promise { - if (!triageId.value) return false - - try { - const result = await submitTriageStep( - triageId.value, - currentStepIndex.value, - selectedLabel, - ) - - // 记录已收集上下文 - collectedContext.value = result.collected_context || collectedContext.value - - // 移动到下一步 - if (result.next_step) { - // 如果后端返回了下一步数据,更新步骤列表 - if (currentStepIndex.value + 1 < triageSteps.value.length) { - triageSteps.value[currentStepIndex.value + 1] = result.next_step - } else { - triageSteps.value.push(result.next_step) - } - currentStepIndex.value++ - return true - } - - // 没有下一步,分诊完成 - return false - } catch (e: any) { - errorMessage.value = e?.message || '提交步骤失败' - return false - } - } - - /** - * 跳过当前步骤 - * @returns 是否还有下一步 - */ - async function skipStep(): Promise { - if (!triageId.value) return false - - try { - const result = await skipTriageStep(triageId.value, currentStepIndex.value) - - if (result.next_step) { - if (currentStepIndex.value + 1 < triageSteps.value.length) { - triageSteps.value[currentStepIndex.value + 1] = result.next_step - } else { - triageSteps.value.push(result.next_step) - } - currentStepIndex.value++ - return true - } - - return false - } catch (e: any) { - errorMessage.value = e?.message || '跳过步骤失败' - return false - } - } - - /** - * 分诊完成,获取 AI 最终回复 - * @returns AI 回复文本 - */ - async function complete(): Promise { - if (!triageId.value) return '' - - try { - const result = await completeTriage(triageId.value, collectedContext.value) - finalReply.value = result.reply - confidence.value = result.confidence - status.value = 'completed' - return result.reply - } catch (e: any) { - errorMessage.value = e?.message || '分诊完成失败' - status.value = 'error' - return '' - } - } - - /** - * 转人工 - * @returns 是否成功 - */ - async function transferToHuman(): Promise { - if (!triageId.value) return false - - try { - await transferTriageToHuman(triageId.value, collectedContext.value) - status.value = 'transferred' - return true - } catch (e: any) { - errorMessage.value = e?.message || '转人工失败' - return false - } - } - - /** - * 设置坐席排除的选项(通过 WS 接收) - * @param labels 要排除的选项标签列表 - */ - function setExcludedOptions(labels: string[]): void { - excludedLabels.value = labels - } - - /** - * 设置坐席推荐的选项(通过 WS 接收) - * @param label 推荐的选项标签 - */ - function setRecommendedOption(label: string): void { - recommendedLabel.value = label - } - - /** - * 重置分诊状态 - */ - function reset(): void { - triageId.value = '' - currentStepIndex.value = 0 - triageSteps.value = [] - totalSteps.value = 0 - collectedContext.value = [] - confidence.value = null - urgency.value = 'medium' - suggestedRoute.value = null - status.value = 'idle' - errorMessage.value = '' - finalReply.value = '' - isTimeout.value = false - excludedLabels.value = [] - recommendedLabel.value = '' - } - - return { - // 状态(只读) - triageId: readonly(triageId), - currentStepIndex: readonly(currentStepIndex), - triageSteps: readonly(triageSteps), - totalSteps: readonly(totalSteps), - collectedContext: readonly(collectedContext), - confidence: readonly(confidence), - urgency: readonly(urgency), - suggestedRoute: readonly(suggestedRoute), - status: readonly(status), - errorMessage: readonly(errorMessage), - finalReply: readonly(finalReply), - isTimeout: readonly(isTimeout), - excludedLabels: readonly(excludedLabels), - recommendedLabel: readonly(recommendedLabel), - - // 计算属性 - currentStep, - currentStepNumber, - isLastStep, - hasNextStep, - isTriaging, - isLoading, - - // 方法 - startTriageFlow, - submitStep, - skipStep, - complete, - transferToHuman, - setExcludedOptions, - setRecommendedOption, - reset, - } -}