From 449c6d487545938cbd4cd926b98cd1113d2c53d9 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 13 Jul 2026 02:17:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=202026-07-12~13=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20-=20AI=E5=AF=B9=E8=AF=9D=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E6=94=B9=E9=80=A0+H5=20v4/v5+=E5=9D=90=E5=B8=AD=E7=AB=AFv5+?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=84=9F=E7=9F=A5=E8=AF=8A=E6=96=AD?= =?UTF-8?q?+=E7=9F=A5=E8=AF=86=E5=BA=93=E8=BF=AD=E4=BB=A33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## H5 员工端 v4 (2026-07-13 00:48 已部署) - 人工按钮三态文案统一为"人工坐席" - 按钮位置移至发送键和语音按钮上方(垂直堆叠) - 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画 - 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V" - 移动端隐藏截图提示(CSS 媒体查询) - AI转人工提示改为"已为您呼叫人工坐席,请稍等!" - 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!" - 删除"摇铃呼叫坐席"入口和文案 - 删除孤儿组件 MessageList.vue + shake 动画 CSS ## H5 员工端 v5 (2026-07-13 02:08 已部署) - RightPanel v2.1:删除"软件安装"和"资源权限"标签页 - 移除标签栏,智能推荐(DynamicRecommend)直接展示 - 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS ## AI 对话链路全栈改造 Phase 1-6 (已部署) - Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级 - Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent - Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传 - Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略 - Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复 - Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s) ## 坐席端 v5 (2026-07-13 01:38 已部署) - ai_structured/byod_card 只读渲染 - AI思考指示器 UI - handleNewMessage 透传 msg_type/extra_data 修复 - 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换 - 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code ## 上下文感知智能诊断闭环 (2026-07-12 已部署) - 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked) - 答题插队 + 五场景关闭 - 迁移052(6表+6列) + queue_service + quiz_service + closing_service - H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard - 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%) ## 知识库迭代3 (2026-07-12 已部署) - 分诊交互(H5+坐席+Dify独立应用) - 拓扑预览(ECharts只读) - 代答排除(4种匹配器: keyword/regex/intent/category) - 迁移051 + 44文件43测试通过 ## 后端变更 - 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等) - funny_phrase_service.py: shake/connected/keyword 默认文案更新 - session_service.py: 企微消息文案同步 - 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点 - 新增: diagnostic.py/quiz.py/triage_session.py 等模型 - 新增: closing_service/queue_service/quiz_service/triage_service 等服务 ## 文档更新 - CHANGELOG.md: 新增 [未发布] 区全部变更记录 - 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定 - 版本记录: 新增v0.7.3条目 - AI对话链路实施计划: Phase 1-6 全部标记✅已实施 - 新增架构图/时序图/类图(mermaid) ## 部署路径修正 - 服务器项目根路径: /opt/wecom-it-desk/ - 所有前端dist均为ro bind mount,只能在宿主机源路径操作 - 服务器nginx /h5/ 是静态文件服务(非proxy_pass) - elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传 --- .gitignore | 14 + .../automation-1782986180887/memory.md | 38 +- .workbuddy/memory/MEMORY.md | 111 +- CHANGELOG.md | 66 +- .../051_add_meetingroom_repair_guide.py | 134 + .../versions/051_triage_exclusion_tables.py | 107 + .../052_diagnostic_queue_quiz_closing.py | 231 + backend/app/api/approval.py | 67 +- backend/app/api/conversations.py | 92 +- backend/app/api/exclusion_rules.py | 381 ++ backend/app/api/h5.py | 309 +- backend/app/api/meetingroom.py | 207 + backend/app/api/queue.py | 108 + backend/app/api/quiz.py | 141 + backend/app/api/quiz_admin.py | 261 + backend/app/api/router.py | 36 + backend/app/api/triage.py | 421 ++ backend/app/api/wecom_jsapi.py | 61 +- backend/app/config.py | 24 + backend/app/main.py | 17 + backend/app/models/__init__.py | 20 + backend/app/models/conversation.py | 63 +- backend/app/models/diagnostic.py | 232 + backend/app/models/exclusion_log.py | 120 + backend/app/models/exclusion_rule.py | 146 + backend/app/models/meetingroom_guide.py | 59 + backend/app/models/meetingroom_repair.py | 69 + backend/app/models/quiz.py | 221 + backend/app/models/triage_session.py | 229 + backend/app/schemas/exclusion.py | 184 + backend/app/schemas/meetingroom.py | 45 + backend/app/schemas/triage.py | 324 ++ backend/app/services/ai_handler.py | 144 +- backend/app/services/ai_service.py | 440 +- backend/app/services/closing_service.py | 922 ++++ backend/app/services/dify_triage_service.py | 284 ++ backend/app/services/exclusion_service.py | 344 ++ backend/app/services/funny_phrase_service.py | 6 +- backend/app/services/it_health_service.py | 646 +++ backend/app/services/matchers/__init__.py | 33 + backend/app/services/matchers/base.py | 55 + .../app/services/matchers/category_matcher.py | 98 + .../app/services/matchers/intent_matcher.py | 142 + .../app/services/matchers/keyword_matcher.py | 67 + .../app/services/matchers/regex_matcher.py | 125 + backend/app/services/meetingroom_service.py | 47 + backend/app/services/queue_service.py | 482 ++ .../app/services/quiz_generation_service.py | 787 +++ backend/app/services/quiz_service.py | 535 ++ backend/app/services/repair_service.py | 220 + backend/app/services/session_service.py | 115 +- backend/app/services/todo_source_service.py | 2 +- backend/app/services/triage_service.py | 990 ++++ backend/app/services/wecom_service.py | 76 +- backend/app/tasks/h5_ai_task.py | 544 +- backend/app/tasks/quiz_generation_task.py | 174 + backend/app/tasks/reminder_task.py | 110 +- backend/create_meetingroom_tables.py | 58 + backend/tests/test_exclusion.py | 820 ++++ backend/tests/test_triage.py | 601 +++ deploy-server/nginx/nginx.conf | 2 + docker-compose.dev.yml | 2 +- docker-compose.yml | 3 + .../AI对话链路全栈改造实施计划-v1.0.md | 731 +++ docs/02-产品需求/dify_main_chat_prompt_v1.md | 189 + .../dify_unified_intent_prompt_v3.md | 245 +- .../05-架构图/ai-assist-class-diagram.mermaid | 137 + .../ai-assist-sequence-diagram.mermaid | 261 + .../IT智能服务台-系统架构设计文档v2.md | 186 +- .../class-exclusion-engine.mermaid | 38 + docs/03-技术架构/dependency-graph.mermaid | 67 + docs/03-技术架构/sequence-triage-flow.mermaid | 74 + .../坐席端AI辅助消息框与布局优化-架构设计.md | 1502 ++++++ .../增量设计-AI辅助消息框-20260711.md | 1047 ++++ .../增量设计-布局优化v2-20260711.md | 1005 ++++ .../增量设计-布局优化v2-时序图.mermaid | 157 + .../增量设计-布局优化v2-类图.mermaid | 222 + ...量设计-知识库迭代-开发任务分解-20260712.md | 1238 +++++ docs/09-部署运维/deploy/03-版本记录.md | 44 +- docs/09-部署运维/会议室预定-部署指南.md | 270 +- .../任务说明书/IT智能服务台-项目管理主文档.md | 28 +- docs/10-项目管理/线性执行计划-20260711.md | 157 + frontend-admin/src/api/exclusion.ts | 147 + frontend-admin/src/api/topology.ts | 69 + frontend-admin/src/components/Sidebar.vue | 21 +- .../exclusion/ExclusionRuleForm.vue | 396 ++ .../exclusion/ExclusionTestDialog.vue | 316 ++ .../src/components/topology/GraphCanvas.vue | 408 ++ .../src/components/topology/GraphToolbar.vue | 203 + .../components/topology/NodeDetailPanel.vue | 396 ++ frontend-admin/src/router/index.ts | 14 + frontend-admin/src/views/ExclusionRules.vue | 522 ++ frontend-admin/src/views/TopologyPreview.vue | 363 ++ frontend-agent/src/api/conversation.ts | 25 +- frontend-agent/src/api/triage.ts | 143 + .../components/assistant/AiAssistantPanel.vue | 1067 +--- .../components/assistant/AiSuggestReply.vue | 210 - .../components/assistant/AiTrainingPanel.vue | 112 + .../assistant/KnowledgeContribute.vue | 340 ++ .../components/assistant/PanelModeToggle.vue | 101 + .../components/assistant/QualityFeedback.vue | 448 ++ .../components/assistant/QuickReplyPanel.vue | 73 +- .../components/assistant/SmartTagEditor.vue | 381 ++ .../src/components/chat/AiDraftBubble.vue | 168 - .../src/components/chat/AiRecommendBar.vue | 334 ++ .../src/components/chat/AiRecommendInline.vue | 258 - .../src/components/chat/ChatArea.vue | 170 +- .../src/components/chat/MessageBubble.vue | 153 +- .../src/components/chat/QuickReplyBar.vue | 604 +++ .../src/components/chat/ReplyBox.vue | 204 +- .../src/components/chat/ReplySuggestArea.vue | 449 ++ .../src/components/chat/TroubleshootBar.vue | 236 +- .../src/components/chat/UserInfoBar.vue | 32 +- .../conversation/ConversationItem.vue | 20 + .../components/triage/TriageDetailPanel.vue | 534 ++ .../components/triage/TriagePendingList.vue | 337 ++ .../src/components/triage/TriageStatsBar.vue | 119 + .../src/composables/useKeyboardShortcuts.ts | 117 +- .../src/composables/useWebSocket.ts | 59 + frontend-agent/src/router/index.ts | 7 + frontend-agent/src/stores/conversation.ts | 175 +- frontend-agent/src/styles/global.css | 17 +- frontend-agent/src/views/TriageDashboard.vue | 444 ++ frontend-agent/src/views/Workspace.vue | 48 +- frontend-h5/components.d.ts | 11 +- frontend-h5/src/api/closing.ts | 123 + frontend-h5/src/api/conversation.ts | 4 +- frontend-h5/src/api/it-health.ts | 81 + frontend-h5/src/api/meetingroom.ts | 150 - frontend-h5/src/api/queue.ts | 109 + frontend-h5/src/api/quiz.ts | 187 + frontend-h5/src/api/triage.ts | 108 + frontend-h5/src/api/wecom.ts | 44 +- frontend-h5/src/components/TriageCard.vue | 346 +- .../assistant/ApplicationProcess.vue | 260 + .../components/assistant/ApprovalLinks.vue | 15 +- .../components/assistant/BasicInfoCard.vue | 604 +++ .../components/assistant/DynamicRecommend.vue | 308 ++ .../assistant/ITHealthDashboard.vue | 789 +++ .../src/components/assistant/QueueWaiting.vue | 1012 ++++ .../src/components/assistant/RightPanel.vue | 927 ++-- .../src/components/assistant/RiskTabs.vue | 512 ++ .../components/assistant/SelfDiagnosis.vue | 924 ++++ .../components/assistant/SoftwareAndApply.vue | 664 +++ .../components/assistant/SoftwareInstall.vue | 418 ++ .../src/components/chat/ApprovalCardModal.vue | 23 +- frontend-h5/src/components/chat/ChatPanel.vue | 154 +- frontend-h5/src/components/chat/InputBar.vue | 330 +- .../src/components/chat/MessageBubble.vue | 96 + .../src/components/chat/MessageList.vue | 244 - .../components/chat/ResolveConfirmCard.vue | 320 ++ frontend-h5/src/composables/useH5WebSocket.ts | 98 + frontend-h5/src/composables/useTriage.ts | 318 ++ .../src/composables/useWecomApproval.ts | 499 ++ frontend-h5/src/router/index.ts | 7 - frontend-h5/src/stores/conversation.ts | 422 +- frontend-h5/src/styles/global.css | 27 - frontend-h5/src/types/wecom-jssdk.d.ts | 98 + frontend-h5/src/views/MeetingroomView.vue | 920 ---- frontend-terminal/index.html | 2 +- frontend-terminal/package-lock.json | 3194 ++++++++++++ frontend-terminal/package.json | 20 +- frontend-terminal/src/api/meetingroom.ts | 35 + .../src/composables/useTerminal.ts | 241 + frontend-terminal/src/router/index.ts | 20 +- frontend-terminal/src/types/meetingroom.ts | 63 + frontend-terminal/src/views/GuideView.vue | 345 ++ frontend-terminal/src/views/RepairView.vue | 345 ++ frontend-terminal/src/views/StatusView.vue | 110 +- frontend-terminal/tailwind.config.js | 4 + frontend-terminal/vite.config.ts | 2 +- nginx/nginx.conf | 65 +- overview.md | 97 +- scripts/dify_export.py | 132 + scripts/dify_export_clean.yaml | 4364 +++++++++++++++++ scripts/dify_export_result.yaml | 1 + 176 files changed, 46637 insertions(+), 4805 deletions(-) create mode 100644 backend/alembic/versions/051_add_meetingroom_repair_guide.py create mode 100644 backend/alembic/versions/051_triage_exclusion_tables.py create mode 100644 backend/alembic/versions/052_diagnostic_queue_quiz_closing.py create mode 100644 backend/app/api/exclusion_rules.py create mode 100644 backend/app/api/queue.py create mode 100644 backend/app/api/quiz.py create mode 100644 backend/app/api/quiz_admin.py create mode 100644 backend/app/api/triage.py create mode 100644 backend/app/models/diagnostic.py create mode 100644 backend/app/models/exclusion_log.py create mode 100644 backend/app/models/exclusion_rule.py create mode 100644 backend/app/models/meetingroom_guide.py create mode 100644 backend/app/models/meetingroom_repair.py create mode 100644 backend/app/models/quiz.py create mode 100644 backend/app/models/triage_session.py create mode 100644 backend/app/schemas/exclusion.py create mode 100644 backend/app/schemas/triage.py create mode 100644 backend/app/services/closing_service.py create mode 100644 backend/app/services/dify_triage_service.py create mode 100644 backend/app/services/exclusion_service.py create mode 100644 backend/app/services/it_health_service.py create mode 100644 backend/app/services/matchers/__init__.py create mode 100644 backend/app/services/matchers/base.py create mode 100644 backend/app/services/matchers/category_matcher.py create mode 100644 backend/app/services/matchers/intent_matcher.py create mode 100644 backend/app/services/matchers/keyword_matcher.py create mode 100644 backend/app/services/matchers/regex_matcher.py create mode 100644 backend/app/services/queue_service.py create mode 100644 backend/app/services/quiz_generation_service.py create mode 100644 backend/app/services/quiz_service.py create mode 100644 backend/app/services/repair_service.py create mode 100644 backend/app/services/triage_service.py create mode 100644 backend/app/tasks/quiz_generation_task.py create mode 100644 backend/create_meetingroom_tables.py create mode 100644 backend/tests/test_exclusion.py create mode 100644 backend/tests/test_triage.py create mode 100644 docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md create mode 100644 docs/02-产品需求/dify_main_chat_prompt_v1.md create mode 100644 docs/03-技术架构/05-架构图/ai-assist-class-diagram.mermaid create mode 100644 docs/03-技术架构/05-架构图/ai-assist-sequence-diagram.mermaid create mode 100644 docs/03-技术架构/class-exclusion-engine.mermaid create mode 100644 docs/03-技术架构/dependency-graph.mermaid create mode 100644 docs/03-技术架构/sequence-triage-flow.mermaid create mode 100644 docs/03-技术架构/坐席端AI辅助消息框与布局优化-架构设计.md create mode 100644 docs/03-技术架构/增量设计-AI辅助消息框-20260711.md create mode 100644 docs/03-技术架构/增量设计-布局优化v2-20260711.md create mode 100644 docs/03-技术架构/增量设计-布局优化v2-时序图.mermaid create mode 100644 docs/03-技术架构/增量设计-布局优化v2-类图.mermaid create mode 100644 docs/03-技术架构/增量设计-知识库迭代-开发任务分解-20260712.md create mode 100644 docs/10-项目管理/线性执行计划-20260711.md create mode 100644 frontend-admin/src/api/exclusion.ts create mode 100644 frontend-admin/src/api/topology.ts create mode 100644 frontend-admin/src/components/exclusion/ExclusionRuleForm.vue create mode 100644 frontend-admin/src/components/exclusion/ExclusionTestDialog.vue create mode 100644 frontend-admin/src/components/topology/GraphCanvas.vue create mode 100644 frontend-admin/src/components/topology/GraphToolbar.vue create mode 100644 frontend-admin/src/components/topology/NodeDetailPanel.vue create mode 100644 frontend-admin/src/views/ExclusionRules.vue create mode 100644 frontend-admin/src/views/TopologyPreview.vue create mode 100644 frontend-agent/src/api/triage.ts delete mode 100644 frontend-agent/src/components/assistant/AiSuggestReply.vue create mode 100644 frontend-agent/src/components/assistant/AiTrainingPanel.vue create mode 100644 frontend-agent/src/components/assistant/KnowledgeContribute.vue create mode 100644 frontend-agent/src/components/assistant/PanelModeToggle.vue create mode 100644 frontend-agent/src/components/assistant/QualityFeedback.vue create mode 100644 frontend-agent/src/components/assistant/SmartTagEditor.vue delete mode 100644 frontend-agent/src/components/chat/AiDraftBubble.vue create mode 100644 frontend-agent/src/components/chat/AiRecommendBar.vue delete mode 100644 frontend-agent/src/components/chat/AiRecommendInline.vue create mode 100644 frontend-agent/src/components/chat/QuickReplyBar.vue create mode 100644 frontend-agent/src/components/chat/ReplySuggestArea.vue create mode 100644 frontend-agent/src/components/triage/TriageDetailPanel.vue create mode 100644 frontend-agent/src/components/triage/TriagePendingList.vue create mode 100644 frontend-agent/src/components/triage/TriageStatsBar.vue create mode 100644 frontend-agent/src/views/TriageDashboard.vue create mode 100644 frontend-h5/src/api/closing.ts create mode 100644 frontend-h5/src/api/it-health.ts delete mode 100644 frontend-h5/src/api/meetingroom.ts create mode 100644 frontend-h5/src/api/queue.ts create mode 100644 frontend-h5/src/api/quiz.ts create mode 100644 frontend-h5/src/api/triage.ts create mode 100644 frontend-h5/src/components/assistant/ApplicationProcess.vue create mode 100644 frontend-h5/src/components/assistant/BasicInfoCard.vue create mode 100644 frontend-h5/src/components/assistant/DynamicRecommend.vue create mode 100644 frontend-h5/src/components/assistant/ITHealthDashboard.vue create mode 100644 frontend-h5/src/components/assistant/QueueWaiting.vue create mode 100644 frontend-h5/src/components/assistant/RiskTabs.vue create mode 100644 frontend-h5/src/components/assistant/SelfDiagnosis.vue create mode 100644 frontend-h5/src/components/assistant/SoftwareAndApply.vue create mode 100644 frontend-h5/src/components/assistant/SoftwareInstall.vue delete mode 100644 frontend-h5/src/components/chat/MessageList.vue create mode 100644 frontend-h5/src/components/chat/ResolveConfirmCard.vue create mode 100644 frontend-h5/src/composables/useTriage.ts create mode 100644 frontend-h5/src/composables/useWecomApproval.ts delete mode 100644 frontend-h5/src/views/MeetingroomView.vue create mode 100644 frontend-terminal/package-lock.json create mode 100644 frontend-terminal/src/composables/useTerminal.ts create mode 100644 frontend-terminal/src/views/GuideView.vue create mode 100644 frontend-terminal/src/views/RepairView.vue create mode 100644 scripts/dify_export.py create mode 100644 scripts/dify_export_clean.yaml create mode 100644 scripts/dify_export_result.yaml diff --git a/.gitignore b/.gitignore index 90b85b3..af0065e 100644 --- a/.gitignore +++ b/.gitignore @@ -233,3 +233,17 @@ backend/scripts/create_test_agent.py # 补充忽略 (2026-07-09 WIP 提交): 新增构建产物 dist-deploy/ dist-v2/ + +# 补充忽略 (2026-07-13): 临时目录 / 备份 / 截图 +.workbuddy/tmp/ +.workbuddy/automations/ +deploy-staging-ki/ +dist-old-*/ +dist_deploy/ +screenshots/ +test-screenshots/ +tools/ +chat_export/ +deliverables/ +02meiti/ +data/ diff --git a/.workbuddy/automations/automation-1782986180887/memory.md b/.workbuddy/automations/automation-1782986180887/memory.md index 477df0c..14858c2 100644 --- a/.workbuddy/automations/automation-1782986180887/memory.md +++ b/.workbuddy/automations/automation-1782986180887/memory.md @@ -1,5 +1,37 @@ # 早班巡检自动化 - 执行记录 +## 2026-07-12 09:30 执行结果 + +**数据来源**:`docs/10-项目管理/任务说明书/IT智能服务台-项目管理主文档.md` (v2.4, 2026-07-10) + `.workbuddy/memory/2026-07-11.md` + `.workbuddy/memory/2026-07-12.md` +**说明**:指定看板路径仍不存在,状态看板在主文档第四章;看板版本滞后2天,07-11/07-12大量产出未入看板 + +### 关键发现 +1. **P0阻塞2项持续未推进**:#48 IP白名单收窄(阻塞≈29天,自06-13)、#81 敏感词检测(阻塞≈8天,自07-04)— 均>3天,需PM立即关注 +2. **#105数据不一致持续3次巡检**:已完成区+P1清单双重列出,07-04/07-10/07-11三次巡检指出至今未修正 +3. **07-11/07-12大量产出未入看板**:代办事项集成(8bug修复链)、IT资产审批推送、语音识别、截图拍照、复杂场景重构、会议室预定系统(全栈部署)、知识库迭代3功能(44文件43测试通过)、知识迭代3Bug修复、坐席v9/v10部署修复、AI辅助消息框+布局优化技术设计文档 +4. **进行中2项**:#91 忘记密码 + #107 后端卷挂载改造(后者07-11已恢复卷挂载,可能已完成需确认) +5. **等用户决策5项**:企微会议室Secret未申请、ITSM API授权待申请、ITSM代办API待抓包、布局优化v2.0的8个待明确事项、知识库迭代待确认 +6. **看板版本严重滞后**:v2.4截止07-10,07-11全日+07-12产出均未入看板 + +### 全局状态 +- P0待办:3项(2项长期阻塞) +- P1待办:5项(1项#105已完成未清理,1项#75可能已完成) +- 等决策:5项 +- 进行中:2项(1项可能已完成) + +### PM行动项 +1. 联系网络组确认代理IP段(#48阻塞29天)⚠️紧急 +2. 确认敏感词库来源/语气优化范围(#81阻塞8天)⚠️紧急 +3. 从P1清单移除#105(连续3次巡检指出)⚠️数据质量 +4. 确认#75头像同步是否已完成(07-08已交付12/12测试) +5. 确认#107卷挂载改造是否已完成(07-11已恢复卷挂载) +6. 企微管理后台申请会议室Secret +7. 向ITSM平台方申请app_id/app_secret +8. 确认布局优化v2.0的8个待明确事项 +9. 更新看板至v2.5+,将07-11/07-12产出纳入已完成区 + +--- + ## 2026-07-11 09:30 执行结果 **数据来源**:`docs/10-项目管理/任务说明书/IT智能服务台-项目管理主文档.md` (v2.4, 2026-07-10) + `.workbuddy/memory/2026-07-11.md` @@ -59,10 +91,10 @@ ## 2026-07-04 09:30 执行结果 **数据来源**:`.taskboard-cache/任务执行状态看板_cache.json`(缓存时间 2026-07-03T08:44:12) -**⚠️ 原始看板文件缺失**:`docs/小组任务书/任务执行状态看板.md` 不存在,本次巡检基于缓存数据 + 07-03巡检记录 + REVIEW_B_T10.md 综合分析 +**⚠️ 原始看板文件缺失**:`docs/小组任务/任务执行状态看板.md` 不存在,本次巡检基于缓存数据 + 07-03巡检记录 + REVIEW_B_T10.md 综合分析 ### 关键发现 -1. **看板源文件丢失**:`docs/小组任务书/任务执行状态看板.md` 路径不存在,该目录也未创建,PRD中有引用但实际文件缺失 +1. **看板源文件丢失**:`docs/小组任务/任务执行状态看板.md` 路径不存在,该目录也未创建,PRD中有引用但实际文件缺失 2. **B-T10双重可激活信号**:①依赖B-T8已完成(33/33 PASS) ②REVIEW_B_T10.md显示代码评审已于07-03通过(IS_PASS: YES),但缓存中仍为⏳等待中 3. **3个阻塞已逾期2天**:BLOCK-17(企微SSO)、BLOCK-19(扫码登录超时)、BLOCK-20(管理后台Network Error) 均 due 07-02,现已逾期2天 4. **BLOCK-18状态矛盾持续**:B-T17标记🟢已修复,但BLOCK-18阻塞表仍为🔵排查中(07-03已发现,至今未修正) @@ -76,7 +108,7 @@ - 整体:39/55 (71%) — 若计入🟢则41/55 (75%) ### PM行动项 -1. **恢复看板源文件** — `docs/小组任务书/任务执行状态看板.md` 缺失,需重建 +1. **恢复看板源文件** — `docs/小组任务/任务执行状态看板.md` 缺失,需重建 2. 通知B组激活B-T10(依赖已完成 + 评审已通过) 3. 优先解决3个逾期阻塞(BLOCK-17/19/20),已逾期2天 4. 确认BLOCK-18/B-T17真实状态并校正看板 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index f3e3395..ded74ff 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -1,9 +1,13 @@ # IT智能服务台 - 项目记忆 ## 设计决策(锁定) -- AI交互:小段多回合;术语:"人工"=用户呼叫坐席,"摇人"=坐席呼叫坐席 +- AI交互:小段多回合;术语:**员工端"人工坐席"按钮** = 用户呼叫坐席(统一命名,不再用"人工"/"摇人"变体);"摇人"=坐席呼叫坐席 - 原型:坐席v5.3 + H5 v1.1;UI:企微浅色扁平,accent=#07C160 - 统一入口 `/itportal/` → user/agent/admin;admin需OTP +- **H5 v4(2026-07-13 00:48 已部署)**:人工按钮三态文案统一为"人工坐席";位置在"发送键和语音按钮上方"(垂直堆叠于 `.input-bar__controls` 容器内);点按钮直接调 `store.shakeAgent()`,不弹 CallAgentModal 浮窗动画;截图说明 PC 显示/移动端隐藏(CSS 媒体查询) +- **H5 v5(2026-07-13 02:08 已部署)**:RightPanel v2.1 — 删除"软件安装"和"资源权限"标签页,移除标签栏,智能推荐直接展示;JS hash `index-BP1rEZIf.js`,CSS hash `index-DC1iZpKe.css` +- **Agent v5(2026-07-13 01:38 已部署)**:ai_structured/byod_card 只读渲染 + AI思考指示器 + handleNewMessage 透传 msg_type/extra_data 修复;JS hash `index-2BTn4SZz.js` +- **后端 v5(2026-07-13 01:38 已部署)**:6个Python文件(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等);diagnosis_stage(6值)+response_time_ms计时+VisionService接入+双WS推送(ai_reply+dynamic_recommend)+ai_thinking同时推员工和坐席 ## 技术架构 - 前端:坐席(Vue3+Element Plus) / H5(Vue3+Vant4) / 管理后台(Vue3+Element+Tailwind) @@ -14,63 +18,78 @@ ## 部署 - 正式服务器:itsupport.servyou.com.cn (10.90.5.110),出口IP `218.75.34.87` - 堡垒机:sxn@10.212.189.210:2222 (OTP),脚本 `C:\Users\simon\.workbuddy\skills\jumpserver-ops\scripts\jms_ops.py` +- **服务器项目根路径**:`/opt/wecom-it-desk/`(非本地仓库路径) - 后端卷挂载:`./app:/app/app`,`.py`变更→`docker compose restart backend`;env变更→`up -d backend` -- 前端:rm旧→tar解压dist/→`restart nginx`;Nginx: H5→`/html/h5` / Agent→`/html/itagent` / Admin→`/html/itadmin` -- Docker bind mount铁律:rm后重建必须重启容器 -- 部署工具:`jms_ops.py pack-upload `(≥100KB用此) -- httpx.Timeout须含default:`httpx.Timeout(timeout=30.0, connect=10.0, read=30.0)` +- **前端部署铁律**:所有前端 dist 均为 ro bind mount,**只能在宿主机源路径操作**,不可在容器内修改 + - H5:`/opt/wecom-it-desk/frontend-h5/dist` → `/usr/share/nginx/html/h5` (ro) + - Agent:`/opt/wecom-it-desk/frontend-agent/dist` → `/usr/share/nginx/html/itagent` (ro) + - Admin:`/opt/wecom-it-desk/frontend-admin/dist` → `/usr/share/nginx/html/itadmin` (ro) + - Portal:`/opt/wecom-it-desk/frontend-portal/dist` → `/usr/share/nginx/html/itportal` (ro) + - Terminal:`/opt/wecom-it-desk/frontend-terminal/dist` → `/usr/share/nginx/html/itterminal` (ro) + - nginx.conf:`/opt/wecom-it-desk/nginx/nginx.conf` → `/etc/nginx/nginx.conf` (ro) + - 部署命令模板:`H5_DIR=/opt/wecom-it-desk/frontend-h5/dist && cp -r $H5_DIR ${H5_DIR}_bak && rm -rf $H5_DIR/* && tar -xzf /tmp/h5-dist-vX.tar.gz -C $H5_DIR/ && docker exec wecom_it_nginx nginx -s reload` +- **服务器 nginx /h5/ 配置**(与本地仓库不同):静态文件服务 `root /usr/share/nginx/html; try_files $uri /h5/index.html;` + `/h5/api/` 反代后端 +- Docker bind mount铁律:rm后重建必须重启容器(或 `nginx -s reload` 热重载) +- **文件上传**:elFinder Web UI(`hz-oa-ai-g-dataquery-90-5-110`目录=服务器`/tmp/`)/ fast_upload_v3.py(~20KB/s)/ jms_ops.py pack-upload +- ⚠️ **elFinder 上传二进制文件不可靠**(2026-07-13 确认):tar.gz 上传后 MD5 不匹配(差90字节)。**推荐用 base64 分块上传**:`jms_ops.py batch` 模式,45KB/块,~113秒/7.6MB(脚本 `.workbuddy/tmp/chunked_upload_v2.py`) +- pscp/plink -T不可用;elFinder上传后需手动mv;httpx.Timeout须含default +- **JumpServer v2.28 变更**:登录新增图片验证码(CAPTCHA);connection-token 端点改为 `/api/v1/authentication/connection-token/` ## 外部集成 - 企微通讯录:Secret `BM6iosc3gKnPqkEXmsQN3ErJUpfO-whfMUN646eezB8`,Redis key=`wecom:contact_access_token` -- Dify:`http://yw-dify.dc.servyou-it.com/v1/chat-messages`;审批意图Key `app-7jkRkAzvX4QM9v9SM3P8mMEO` +- Dify:`http://yw-dify.dc.servyou-it.com/v1/chat-messages`;审批意图Key `app-7jkRkAzvX4QM9v9SM3P8mMEO`;分诊Key `app-z3S9AEUUAVPbtR2rioxpiIvp` +- ⚠️ **禁止使用** `app-UaTWYdBSwN6VktKQlbh5YN5H`(老线上Dify应用,未收到明确指令前不可调用);应使用副本 `app-7jkRkAzvX4QM9v9SM3P8mMEO` 或自建 `app-J3s8sHarZQ2SCaNF3xCppliL`(后者不适合dify2openai代理,仅限直接API调用) - RAGFlow:生产 `http://10.80.0.85:8080/` / API `:9380` - 映射策略:联软(主) > aTrust(VPN) > eHR(静态) -## 群聊系统 -- 摇人(`collaborating_agent_ids`) / 邀请(`participants`);四角色权限 -- 参与者展开/缩略双模式;头像代理 `/api/avatar/proxy` 解决 COEP/CSP +## 企微JS-SDK技术 +- 双鉴权:`wx.config()`(jsapi_ticket) + `wx.agentConfig()`(agent_config_ticket),签名算法相同但**不能混用** +- `wx.invoke('thirdPartyOpenPage', {oaType:'10001', templateId, thirdNo, extData})` 原生打开审批表单 +- 后端端点:`GET /wecom/jsapi-config?url=...&with_agent_config=true` +- 前端composable:`frontend-h5/src/composables/useWecomApproval.ts`(懒加载+全降级+超时保护) +- 既有bug:`EmergencyDispatcher.vue` 第99-119行 复用jsapi签名给agentConfig(靠3秒超时兜底) -## 审批流程系统(2026-07-10上线) -- 12种审批类型 / 18个流程;意图识别三级链路:关键词→Dify→降级 -- 卡片导航用 `window.location.href`(同窗口) +## 已上线功能模块 +- 群聊(摇人/邀请/四角色) / 审批(12类型18流程/三级意图) / 代办(getapprovalinfo+Semaphore/缓存45s) +- IT资产推送(模板`Bs7ucT...`) / 语音转文字(手机JS-SDK/PC百度ASR) / 截图拍照 / 复杂场景P0~P3 +- 会议室预定(终端`/itterminal/`,企微会议室Secret待申请) -## 代办事项真实数据源集成(2026-07-11部署+修复完成) -- **PRD**:`docs/02-产品需求/prd_todo_integration.md` -- **后端**:`todo_source_service.py` / `itsm_service.py` / `todo_aggregator_service.py`(Redis缓存45s) -- **前端**:`TodoPanel.vue` / `todo.ts`(API+Store) -- **企微审批API**:`getapprovalinfo`(旧`getapprovaldata`已废弃404)→ Semaphore(10)并发`getapprovaldetail` → 代码层过滤template_id -- **Token方案**:统一用 `TokenManager`(IT支持应用Secret,IP已在白名单) -- **缓存策略**:初始+60s自动=用缓存(无_force) / 手动🔄=跳过缓存(_force=1) / TTL=45s -- **8个Bug修复链**:env未注入→Redis密码→bind mount丢失→API废弃→token decode→IP白名单60020→filter参数301025→**_extract_current_approver字段名全错** -- **企微API实际字段名**(dump确认):`sp_record[].sp_status`(非status)/ `details[].approver.userid`(非approver[].userid) -- **验证**:sxn名下2条审批单正确返回(IT资产外修申请) -- **ITSM**:工单详情API已实现,代办列表API待抓包;签名 app_id+app_secret+SHA1 -- **测试**:40/40通过 +## 坐席端布局优化v2.0(2026-07-12 部署) +- 8新增+7修改+3删除;QuickReplyBar L1+L2悬浮;ReplyBox左右分区;右栏260↔560px模式切换 +- **键盘快捷键v2.3**:纯数字1~9上下文路由(AI/L1/L2);ESC分层撤销;Shift+Space用`event.code`匹配(不受IME影响) +- `useKeyboardShortcuts.ts`中央管理器,IME/ScreenCapture守卫;L1 chip移除模板数量徽章只保留kbd编号 -## IT资产升级审批推送(待部署) -- 模板ID:`Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS` -- 新建 `asset_service.py`;修改 `approval.py`(+urge端点) / `config.py`(+asset_excel_path) -- 资产Excel:12月度sheet,编码列(2)匹配,日期列(41)算年限 +## 知识库迭代3功能(2026-07-12 部署) +- 分诊交互(H5+坐席+Dify独立应用) + 拓扑预览(ECharts只读) + 代答排除(4种匹配器) +- 44文件43测试通过;迁移051;路由顺序铁律:固定路径必须在参数路由前注册 -## 语音识别转文字(2026-07-11部署) -- 手机企微=JS-SDK / PC企微=百度ASR(AppID=123947532) / Mac=隐藏 -- H5内联录音:🎤→⏹→⏳(disabled) +## 上下文感知智能诊断→修复闭环(2026-07-12 已部署) +- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked) + 答题插队 + 五场景关闭 +- 后端:迁移052(6表+6列) / queue_service / quiz_service / closing_service / seed_quiz / 每日3:00定时生成 +- H5前端:QueueWaiting / RightPanel双Tab / InputBar三态"人工"按钮 / ResolveConfirmCard +- 坐席前端:pending_close结单流程;信息锁定(Dify步骤完成+有效回答率≥70%) +- **部署时间**:2026-07-12 21:24(H5前端通过jms_ops.py upload elFinder通道上传7.61MB) +- **验证**:Queue API 200 ✅ / Quiz API 200 ✅ / H5页面200+新JS hash ✅ / Agent前端v2.3 ✅ / Nginx healthy ✅ -## 坐席端截图+拍照(2026-07-11部署) -- 截图编辑器(马赛克/文字/箭头/矩形/撤销);拍照(getUserMedia→兜底input capture) -- 铁律:`.selection-box`必须`pointer-events:none`;文字工具CSS类切换非v-if;`getDisplayMedia()`必须同步调用 - -## 复杂场景重构(2026-07-11部署) -- P0~P3:任务中断恢复/信息更正/上下文压缩/多轮纠错 -- 路由前缀`/itportal/automation/`;迁移链修复041→028_merge_heads - -## 知识库迭代(待确认) -- 技术方案:`docs/03-技术架构/增量设计-知识库迭代与痛点缓解-20260711.md` -- 原型图:`docs/01-产品设计/知识库迭代-未实现功能原型设计-20260711.md` - -## 近期修复 (2026-07) -- H5非企微环境→扫码登录页;Vue版本不一致白屏→重装+`ElMessage._context` -- 消息重复/头像COEP/OAuth残留→已修 +## Dify App改造 + 右边栏v2(2026-07-12 设计确认 → Phase 1-3 已完成) +- **Dify App现状**:85节点→计划精简至~35;保留RAGFlow+Vision节点(后端未接入前不删) +- **单通道统一消息架构**:Dify输出JSON `{text, action, options}` → 后端发两条WS(ai_reply+dynamic_recommend)→ 文字到聊天气泡/卡片到侧边栏 +- **审批意图优化**:删除前端`checkApprovalIntent()`;关键词预过滤收窄(~40→~25);两级分类(4粗→12细);后端统一入口 +- **右边栏v2.1已实施**(v2.1 2026-07-13:删除软件安装/资源权限标签,全面AI化): + - 手风琴两大区域:设备信息(默认折叠) / 自助诊断(默认折叠) + - 智能推荐区域:DynamicRecommend 组件直接展示(无标签栏切换,始终可见) + - 设备信息:CPU/内存/硬盘默认隐藏(避免焦虑) + - 自助诊断标签:网络联通/账号权限/设备硬件 +- **Phase 1-3 完成状态**(2026-07-12): + - Phase 1 ✅:Dify Prompt(JSON输出) + 后端blocking+JSON解析+双WS推送 + 错误降级(30s超时/15s still_thinking) + - Phase 2 ✅:关键词收窄(~25强意图词) + 两级分类Prompt v4.0 + 删除前端checkApprovalIntent + - Phase 3 ✅:WS扩展(ai_thinking+dynamic_recommend) + MessageBubble ai_structured渲染(文字+选项按钮) + RightPanel v2.1(手风琴+智能推荐直接展示) + DynamicRecommend.vue(新建) + sendOptionSelect WS回传 + - **Phase 4 ✅**:VisionService接入(`_enrich_image_content`+`_fetch_recent_employee_text`5秒融合) + 图片消息跳过关键词拦截 + 降级策略 + - **Phase 5 ✅**:坐席端`ai_thinking` WS+指示器UI + `MessageBubble` ai_structured/byod_card渲染 + `handleNewMessage`修复(msg_type/extra_data透传) + - **Phase 6 ✅**:`diagnosis_stage`字段(6种值) → `closing_service`辅助方法 + `response_time_ms`计时+慢响应告警(>10s) +- **v2.0 新增前端文件**:`DynamicRecommend.vue`(动态推荐卡片,3种类型 approval/action/info) +- **v2.0 关键架构**:`sendWsMessage()` 模块级导出函数(useH5WebSocket.ts),供 store 在 composable 外部发送 WS 消息 +- **实施计划文档**:`docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md`(6阶段Phase 1-6,10个Task #59-#69跟踪) ## 运维工具 - SOP:`docs/10-项目管理/IT智能服务台-标准作业流程SOP.md` diff --git a/CHANGELOG.md b/CHANGELOG.md index ff05a46..41d5d77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,60 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 -## [未发布] - 2026-07-11 +## [未发布] - 2026-07-13 + +### 🚀 功能增强 (Features) + +#### Agent 坐席端 v5 — AI 结构化消息 + 思考指示器(2026-07-13 01:38 部署) +- **ai_structured 只读渲染**:坐席端 `MessageBubble.vue` 新增 AI 结构化消息渲染分支(文字 + 只读选项标签 + 推荐摘要) +- **byod_card 渲染**:补全之前缺失的 `byod_card` 消息类型渲染分支 +- **AI 思考指示器**:`ChatArea.vue` 新增 `aiThinkingText` 计算属性 + 脉冲动画 CSS,坐席可实时看到 AI 正在思考 +- **handleNewMessage 透传修复**:修复 `msg_type` 和 `extra_data` 硬编码为 `'text'` 的问题,正确透传消息类型 +- **ai_thinking 双推**:后端 `ai_thinking` WS 消息同时推送给员工端和坐席端 +- 验证:JS hash `index-2BTn4SZz.js` ✅,5 容器全部 healthy ✅ + +#### H5 员工端 v4 — 人工坐席交互改造(#116, 2026-07-13 部署) +- **人工按钮三态文案统一**为"人工坐席"(原"人工(需更多对话)"等多态文案) +- **按钮位置调整**:移至发送键 + 语音转文字图标上方(`.input-bar__controls` 容器内垂直堆叠) +- **删除 CallAgentModal 弹窗动画**:点击按钮直接调用 `store.shakeAgent()`,无中间浮窗 +- **截图快捷键提示改版**:改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V",排列在工具图标后 +- **移动端隐藏截图提示**:CSS `@media (max-width: 768px)` 媒体查询 +- **AI 转人工提示**:"已为您呼叫人工坐席,请稍等!"(原"少主,折旧为您去摇人,稍等….") +- **坐席接入提示**:"坐席正在查看您的信息,请等待处理回复!"(原"坐席已为您服务,请稍后….") +- **删除"摇铃呼叫坐席"入口和文案** +- **清理孤儿组件** `MessageList.vue`(全项目无引用) +- **删除 CSS** `@keyframes shake` 动画及相关变量 +- **DB 同步**:PostgreSQL `funny_phrases` 表 3 条记录(shake/connected/keyword)UPDATE +- 验证:JS hash `index-eQVEQIDL.js` → `index-B6dzwk-X.js` ✅,JS 包内容检查通过 + +#### AI 对话链路全栈改造 Phase 1-6(#59-#69, 2026-07-13 01:38 生产部署) +- **Phase 1 ✅**:Dify Prompt JSON 输出 + 后端 blocking + JSON 解析 + 双 WS 推送 + 错误降级(30s 超时 / 15s still_thinking) +- **Phase 2 ✅**:审批关键词收窄(~40→~25 强意图词)+ 两级分类 Prompt v4.0(4粗→12细)+ 删除前端 `checkApprovalIntent()` +- **Phase 3 ✅**:WS 扩展(`ai_thinking` + `dynamic_recommend`)+ `MessageBubble` ai_structured 渲染 + `RightPanel` v2(手风琴 + 底部标签)+ `DynamicRecommend.vue`(新建)+ `sendOptionSelect` WS 回传 +- **Phase 4 ✅**:`VisionService` 接入(`_enrich_image_content` + `_fetch_recent_employee_text` 5秒融合)+ 图片消息跳过关键词拦截 + 降级策略 +- **Phase 5 ✅**:坐席端 `ai_thinking` WS + 指示器 UI + `MessageBubble` ai_structured/byod_card 渲染 + `handleNewMessage` 修复 +- **Phase 6 ✅**:`diagnosis_stage` 字段(6种值)→ `closing_service` 辅助方法 + `response_time_ms` 计时 + 慢响应告警(>10s) + +#### 上下文感知智能诊断→修复闭环(2026-07-12 部署) +- **三层诊断**:API → Script → AI 递进式诊断 +- **三段排队**:VIP → info_locked → not locked +- **答题插队**:员工答题期间优先处理 +- **五场景关闭**:五种场景自动关闭会话 +- 后端:迁移 052(6表+6列)/ `queue_service` / `quiz_service` / `closing_service` / `seed_quiz` / 每日3:00定时生成 +- H5 前端:`QueueWaiting` / `RightPanel` 双Tab / `InputBar` 三态"人工"按钮 / `ResolveConfirmCard` +- 坐席前端:`pending_close` 结单流程;信息锁定(Dify 步骤完成 + 有效回答率≥70%) + +#### 坐席端布局优化 v2.0(2026-07-12 部署) +- 8 新增 + 7 修改 + 3 删除 +- `QuickReplyBar` L1+L2 悬浮;`ReplyBox` 左右分区;右栏 260↔560px 模式切换 +- **键盘快捷键 v2.3**:纯数字 1~9 上下文路由(AI/L1/L2);ESC 分层撤销;Shift+Space 用 `event.code` 匹配(不受 IME 影响) +- `useKeyboardShortcuts.ts` 中央管理器,IME/ScreenCapture 守卫 + +#### 知识库迭代 3 功能(2026-07-12 部署) +- 分诊交互(H5+坐席+Dify 独立应用) +- 拓扑预览(ECharts 只读) +- 代答排除(4种匹配器) +- 44 文件 43 测试通过;迁移 051 ### 🐛 缺陷修复 (Bug Fixes) - 修复:代办事项企微审批 API 返回空列表(8 个问题逐一修复) @@ -18,12 +71,6 @@ 7. errcode=301025 invalid filter → 企微 API 每个 filter key 只能出现一次,去掉 API 层 template_id 过滤,改代码层过滤 8. `_extract_current_approver` 字段名全错 → `record.status` 改 `record.sp_status`,`record.approver[]` 改 `record.details[].approver.userid`(经 JSON dump 确认实际 API 返回结构) - 修复:验证通过,sxn 名下 2 条审批待办正确返回(IT 资产外修申请) - -### 🚀 功能增强 (Features) -- 蓝绿部署支持:新增 docker-compose-green.yml、switch-blue-green.sh、nginx-green-upstream.conf -- Green 环境端口:后端 5002,Nginx 5080/5443 - -### 🐛 缺陷修复 (Bug Fixes) - 修复:nginx 容器配置丢失导致页面加载失败 - 修复:后端 h5.py `_require_wework_ua` NameError 导致 OAuth 认证失败 @@ -36,11 +83,15 @@ - Gitea 凭据走 wincred,不入文件 ### 🏗️ 基础设施 (Infrastructure) +- 蓝绿部署支持:新增 docker-compose-green.yml、switch-blue-green.sh、nginx-green-upstream.conf +- Green 环境端口:后端 5002,Nginx 5080/5443 - Gitea 自托管部署(Synology 套件 8418 端口) - Tailscale Funnel 暴露给 workbuddy 沙箱 - 分支保护:main 需 PR + 1 reviewer - workbuddy-claude 配 access token + 自动跑批 - 备份脚本(7 天保留 + cron 3 点) +- **服务器部署路径修正**:确认服务器项目根路径 `/opt/wecom-it-desk/`,所有前端 dist 均为 ro bind mount +- **前端部署命令模板**:`H5_DIR=/opt/wecom-it-desk/frontend-h5/dist && cp -r $H5_DIR ${H5_DIR}_bak && rm -rf $H5_DIR/* && tar -xzf /tmp/h5-dist-vX.tar.gz -C $H5_DIR/ && docker exec wecom_it_nginx nginx -s reload` ### 📚 文档 (Documentation) - 新增 8 份审计/设计报告(Dockerfile / ER / 依赖 / 健康检查 / CORS / 一键部署 / 健康度 / 惊喜汇总) @@ -49,6 +100,7 @@ - 2 份路线图(阶段 1 盘点 + 阶段 4-5 规划) - Wingman 设计文档 - 4 前端审计 + 16 项统一优化路线 +- AI 对话链路全栈改造实施计划 v1.0(`docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md`) ### 🛠️ 工具链 (Tooling) - `scripts/pre-commit-check.sh`:4 件套预检(鉴权+依赖+alembic+配置) diff --git a/backend/alembic/versions/051_add_meetingroom_repair_guide.py b/backend/alembic/versions/051_add_meetingroom_repair_guide.py new file mode 100644 index 0000000..710d6f0 --- /dev/null +++ b/backend/alembic/versions/051_add_meetingroom_repair_guide.py @@ -0,0 +1,134 @@ +"""add meetingroom repair and guide tables with seed data + +Revision ID: 051_add_meetingroom_repair_guide +Revises: 050_add_meetingroom_tables +Create Date: 2026-07-16 + +新增表: + - meetingroom_guide: 会议室操作指南(终端大屏展示+二维码) + - meetingroom_repair: 会议室报修记录(关联IT工单会话) +同时插入操作指南种子数据 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# Alembic 修订标识符 +revision: str = "051_add_meetingroom_repair_guide" +down_revision: Union[str, None] = "050_add_meetingroom_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """升级:创建 meetingroom_guide 和 meetingroom_repair 表,插入种子数据。""" + + # 1. 操作指南表 + op.create_table( + "meetingroom_guide", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("category", sa.String(50), nullable=False), + sa.Column("title", sa.String(100), nullable=False), + sa.Column("brief", sa.Text(), nullable=False, server_default=""), + sa.Column("detail_url", sa.String(500), nullable=False, server_default=""), + sa.Column("icon", sa.String(50), nullable=False, server_default="📋"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_meetingroom_guide_category", "meetingroom_guide", ["category"]) + + # 2. 报修记录表 + op.create_table( + "meetingroom_repair", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("terminal_sn", sa.String(64), nullable=False), + sa.Column("meetingroom_id", sa.Integer(), nullable=False), + sa.Column("meetingroom_name", sa.String(100), nullable=False, server_default=""), + sa.Column("device_type", sa.String(50), nullable=False), + sa.Column("fault_description", sa.Text(), nullable=False), + sa.Column("reporter_name", sa.String(100), nullable=False, server_default="匿名"), + sa.Column("reporter_userid", sa.String(64), nullable=False, server_default=""), + sa.Column("conversation_id", sa.String(36), nullable=False), + sa.Column("status", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_meetingroom_repair_terminal_sn", "meetingroom_repair", ["terminal_sn"]) + op.create_index("ix_meetingroom_repair_meetingroom_id", "meetingroom_repair", ["meetingroom_id"]) + op.create_index("ix_meetingroom_repair_conversation_id", "meetingroom_repair", ["conversation_id"]) + + # 3. 插入操作指南种子数据 + guide_table = sa.table( + "meetingroom_guide", + sa.column("category", sa.String), + sa.column("title", sa.String), + sa.column("brief", sa.Text), + sa.column("detail_url", sa.String), + sa.column("icon", sa.String), + sa.column("sort_order", sa.Integer), + sa.column("is_active", sa.Boolean), + ) + + op.bulk_insert(guide_table, [ + { + "category": "projector", + "title": "投影仪使用指南", + "brief": "1. 按遥控器电源键开机\n2. 选择信号源(HDMI1/2)\n3. 调整焦距和梯形校正\n4. 使用完毕按电源键关机\n5. 等待散热完成后断电", + "detail_url": "", + "icon": "📽️", + "sort_order": 1, + "is_active": True, + }, + { + "category": "video_conf", + "title": "视频会议使用指南", + "brief": "1. 在终端主页选择"视频会议"\n2. 输入会议号或选择预约会议\n3. 点击"加入会议"\n4. 共享屏幕:点击"共享"按钮\n5. 会议结束:点击"挂断"", + "detail_url": "", + "icon": "📹", + "sort_order": 2, + "is_active": True, + }, + { + "category": "aircon", + "title": "空调控制指南", + "brief": "1. 遥控器对准空调内机\n2. 按电源键开关机\n3. 模式键切换:制冷/制热/除湿\n4. 温度调节:↑/↓ 键\n5. 风速调节:低/中/高/自动", + "detail_url": "", + "icon": "❄️", + "sort_order": 3, + "is_active": True, + }, + { + "category": "phone", + "title": "会议室电话使用指南", + "brief": "1. 摘机或按免提键\n2. 拨打号码后按#键确认\n3. 转接:按转接键 → 输入分机号\n4. 音量调节:侧面音量键\n5. 挂机结束通话", + "detail_url": "", + "icon": "📞", + "sort_order": 4, + "is_active": True, + }, + { + "category": "other", + "title": "其他设备指南", + "brief": "如需查看其他设备(电子白板、音响、灯光等)的使用说明,请扫描右侧二维码查看完整设备手册。", + "detail_url": "", + "icon": "📋", + "sort_order": 5, + "is_active": True, + }, + ]) + + +def downgrade() -> None: + """回滚:删除 meetingroom_repair 和 meetingroom_guide 表。""" + op.drop_index("ix_meetingroom_repair_conversation_id", table_name="meetingroom_repair") + op.drop_index("ix_meetingroom_repair_meetingroom_id", table_name="meetingroom_repair") + op.drop_index("ix_meetingroom_repair_terminal_sn", table_name="meetingroom_repair") + op.drop_table("meetingroom_repair") + + op.drop_index("ix_meetingroom_guide_category", table_name="meetingroom_guide") + op.drop_table("meetingroom_guide") diff --git a/backend/alembic/versions/051_triage_exclusion_tables.py b/backend/alembic/versions/051_triage_exclusion_tables.py new file mode 100644 index 0000000..b089cdf --- /dev/null +++ b/backend/alembic/versions/051_triage_exclusion_tables.py @@ -0,0 +1,107 @@ +"""triage_sessions + exclusion_rules + exclusion_logs + +Revision ID: 051_triage_exclusion +Revises: 050_add_meetingroom_tables +Create Date: 2026-07-12 + +新增表: + - triage_sessions: AI 分诊会话记录 + - exclusion_rules: 代答排除规则 + - exclusion_logs: 排除命中日志 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "051_triage_exclusion" +down_revision: Union[str, None] = "050_add_meetingroom_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. triage_sessions 表 — AI 分诊会话 + op.create_table( + "triage_sessions", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("conversation_id", sa.String(36), nullable=False), + sa.Column("user_id", sa.String(100), nullable=False), + sa.Column("user_name", sa.String(100)), + sa.Column("user_dept", sa.String(100)), + sa.Column("user_level", sa.String(20)), + sa.Column("device_info", sa.String(200)), + sa.Column("request_title", sa.String(200), nullable=False), + sa.Column("request_content", sa.Text, nullable=False), + sa.Column("source", sa.String(50), server_default="wecom_h5"), + sa.Column("problem_type", sa.String(50)), + sa.Column("problem_category", sa.String(100)), + sa.Column("confidence", sa.Float), + sa.Column("urgency", sa.String(20), server_default="medium"), + sa.Column("suggested_route", sa.String(50)), + sa.Column("matched_knowledge", sa.String(500)), + sa.Column("match_score", sa.Float), + sa.Column("context_tags", sa.JSON, server_default="[]"), + sa.Column("triage_steps", sa.JSON, server_default="[]"), + sa.Column("collected_context", sa.JSON, server_default="[]"), + sa.Column("status", sa.String(30), server_default="pending"), + sa.Column("route_action", sa.String(50)), + sa.Column("route_note", sa.Text), + sa.Column("operator_id", sa.String(100)), + sa.Column("operated_at", sa.DateTime), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("idx_triage_status", "triage_sessions", ["status"]) + op.create_index("idx_triage_urgency", "triage_sessions", ["urgency"]) + op.create_index("idx_triage_conversation", "triage_sessions", ["conversation_id"]) + op.create_index("idx_triage_created", "triage_sessions", ["created_at"]) + op.create_index("idx_triage_user", "triage_sessions", ["user_id"]) + + # 2. exclusion_rules 表 — 代答排除规则 + op.create_table( + "exclusion_rules", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("rule_name", sa.String(200), nullable=False, unique=True), + sa.Column("rule_description", sa.Text), + sa.Column("priority", sa.String(5), nullable=False, server_default="P2"), + sa.Column("match_type", sa.String(20), nullable=False), + sa.Column("match_condition", sa.Text, nullable=False), + sa.Column("match_scope", sa.JSON, nullable=False, server_default='["ai_auto_reply"]'), + sa.Column("action_type", sa.String(50), nullable=False, server_default="transfer_human"), + sa.Column("transfer_message", sa.Text), + sa.Column("status", sa.String(10), nullable=False, server_default="enabled"), + sa.Column("hit_count", sa.Integer, server_default="0"), + sa.Column("created_by", sa.String(100), nullable=False), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("idx_exclusion_rules_status", "exclusion_rules", ["status"]) + op.create_index("idx_exclusion_rules_priority", "exclusion_rules", ["priority"]) + op.create_index("idx_exclusion_rules_match_type", "exclusion_rules", ["match_type"]) + + # 3. exclusion_logs 表 — 排除命中日志 + op.create_table( + "exclusion_logs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("rule_id", sa.String(36), nullable=False), + sa.Column("rule_name", sa.String(200)), + sa.Column("conversation_id", sa.String(36)), + sa.Column("user_id", sa.String(100)), + sa.Column("message_content", sa.Text), + sa.Column("match_type", sa.String(20)), + sa.Column("matched_detail", sa.Text), + sa.Column("action_type", sa.String(50)), + sa.Column("action_result", sa.String(50), server_default="success"), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_index("idx_exclusion_logs_rule", "exclusion_logs", ["rule_id"]) + op.create_index("idx_exclusion_logs_created", "exclusion_logs", ["created_at"]) + op.create_index("idx_exclusion_logs_conversation", "exclusion_logs", ["conversation_id"]) + + +def downgrade() -> None: + op.drop_table("exclusion_logs") + op.drop_table("exclusion_rules") + op.drop_table("triage_sessions") diff --git a/backend/alembic/versions/052_diagnostic_queue_quiz_closing.py b/backend/alembic/versions/052_diagnostic_queue_quiz_closing.py new file mode 100644 index 0000000..ab3a960 --- /dev/null +++ b/backend/alembic/versions/052_diagnostic_queue_quiz_closing.py @@ -0,0 +1,231 @@ +"""diagnostic + queue + quiz + closing mechanism + +Revision ID: 052_diag_queue_quiz +Revises: 051_triage_exclusion +Create Date: 2026-07-12 + +新增表(6张): + - diagnostic_templates: 原子化诊断检查项模板库 + - diagnostic_dispatches: 诊断下发记录(dispatched→executed→analyzed→resolved) + - diagnostic_reports: 诊断报告存储 + AI分析结论 + - quiz_questions: IT知识题库 + 诊断题 + - quiz_answers: 答题记录 + - employee_points: 员工积分账户(跨会话累积,5级等级) + +Conversation表新增字段(6个): + - queue_priority: 答题插队优先级(min(答题数//3, 2),上限2) + - info_locked: 信息是否锁定(Dify步骤完成+有效率≥70%) + - resolved_by: 关闭方(employee/agent/ai/system_timeout) + - resolved_method: 关闭方式(ai_self/agent_confirm/employee_initiative/auto_timeout) + - resolve_summary: 结单摘要(问题类型+根因+解决方式) + - reference_conversation_id: 重开时关联的原会话ID +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "052_diag_queue_quiz" +down_revision: Union[str, None] = "051_triage_exclusion" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ===================================================================== + # 1. Conversation表新增字段 + # ===================================================================== + op.add_column("conversations", sa.Column( + "queue_priority", sa.Integer, nullable=False, server_default="0", + comment="答题插队优先级(上限2)" + )) + op.add_column("conversations", sa.Column( + "info_locked", sa.Boolean, nullable=False, server_default=sa.text("false"), + comment="信息是否锁定" + )) + op.add_column("conversations", sa.Column( + "resolved_by", sa.String(20), nullable=True, + comment="关闭方: employee/agent/ai/system_timeout" + )) + op.add_column("conversations", sa.Column( + "resolved_method", sa.String(30), nullable=True, + comment="关闭方式: ai_self/agent_confirm/employee_initiative/auto_timeout" + )) + op.add_column("conversations", sa.Column( + "resolve_summary", sa.Text, nullable=True, + comment="结单摘要" + )) + op.add_column("conversations", sa.Column( + "reference_conversation_id", sa.String(36), nullable=True, + comment="重开时关联的原会话ID" + )) + + # 为排队查询添加索引 + op.create_index( + "idx_conversations_queue_priority", + "conversations", + ["queue_priority"] + ) + op.create_index( + "idx_conversations_info_locked", + "conversations", + ["info_locked"] + ) + + # ===================================================================== + # 2. diagnostic_templates — 诊断模板库 + # ===================================================================== + op.create_table( + "diagnostic_templates", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("category", sa.String(50), nullable=False, comment="问题类别"), + sa.Column("name", sa.String(200), nullable=False, comment="检查项名称"), + sa.Column("check_type", sa.String(20), nullable=False, server_default="api", + comment="检查类型: api/script"), + sa.Column("api_source", sa.String(50), nullable=True, comment="API来源: huorong/lianruan"), + sa.Column("api_method", sa.String(100), nullable=True, comment="调用的API方法名"), + sa.Column("script_template", sa.Text, nullable=True, comment="脚本模板"), + sa.Column("fix_template", sa.Text, nullable=True, comment="修复脚本模板"), + sa.Column("fix_risk_level", sa.String(20), nullable=False, server_default="medium", + comment="修复风险等级: low/medium/high"), + sa.Column("target_condition", sa.String(200), nullable=True, comment="触发条件"), + sa.Column("description", sa.Text, nullable=True, comment="检查项描述"), + sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true"), + comment="是否启用"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("idx_diag_tpl_category", "diagnostic_templates", ["category"]) + op.create_index("idx_diag_tpl_type", "diagnostic_templates", ["check_type"]) + op.create_index("idx_diag_tpl_active", "diagnostic_templates", ["is_active"]) + + # ===================================================================== + # 3. diagnostic_dispatches — 诊断下发记录 + # ===================================================================== + op.create_table( + "diagnostic_dispatches", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("conversation_id", sa.String(36), nullable=False, comment="关联会话ID"), + sa.Column("employee_id", sa.String(64), nullable=False, comment="员工ID"), + sa.Column("template_ids", sa.JSON, nullable=False, server_default="[]", + comment="诊断模板ID列表"), + sa.Column("script_content", sa.Text, nullable=True, comment="生成的脚本内容"), + sa.Column("script_hash", sa.String(64), nullable=True, comment="脚本SHA256哈希"), + sa.Column("upload_token", sa.String(128), nullable=True, comment="一次性上传token"), + sa.Column("status", sa.String(20), nullable=False, server_default="dispatched", + comment="状态: dispatched/executed/analyzed/resolved"), + sa.Column("report_id", sa.String(36), nullable=True, comment="关联诊断报告ID"), + sa.Column("fix_dispatched", sa.Boolean, nullable=False, server_default=sa.text("false"), + comment="是否已下发修复包"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True, comment="完成时间"), + ) + op.create_index("idx_diag_dispatch_conv", "diagnostic_dispatches", ["conversation_id"]) + op.create_index("idx_diag_dispatch_employee", "diagnostic_dispatches", ["employee_id"]) + op.create_index("idx_diag_dispatch_status", "diagnostic_dispatches", ["status"]) + + # ===================================================================== + # 4. diagnostic_reports — 诊断报告存储 + # ===================================================================== + op.create_table( + "diagnostic_reports", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("dispatch_id", sa.String(36), nullable=True, comment="关联下发记录ID"), + sa.Column("conversation_id", sa.String(36), nullable=False, comment="关联会话ID"), + sa.Column("employee_id", sa.String(64), nullable=False, comment="员工ID"), + sa.Column("template_ids", sa.JSON, nullable=False, server_default="[]", + comment="涉及诊断模板ID列表"), + sa.Column("report_data", sa.JSON, nullable=False, server_default="{}", + comment="检查结果JSON"), + sa.Column("ai_analysis", sa.JSON, nullable=True, comment="AI分析结论JSON"), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", + comment="报告状态: pending/analyzed/resolved"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("idx_diag_report_conv", "diagnostic_reports", ["conversation_id"]) + op.create_index("idx_diag_report_employee", "diagnostic_reports", ["employee_id"]) + op.create_index("idx_diag_report_status", "diagnostic_reports", ["status"]) + + # ===================================================================== + # 5. quiz_questions — IT知识题库 + # ===================================================================== + op.create_table( + "quiz_questions", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("type", sa.String(20), nullable=False, server_default="knowledge", + comment="题目类型: knowledge/diagnostic"), + sa.Column("category", sa.String(50), nullable=False, comment="题目类别"), + sa.Column("problem_category", sa.String(100), nullable=True, + comment="诊断题对应的问题类别"), + sa.Column("difficulty", sa.String(20), nullable=False, server_default="medium", + comment="难度: easy/medium/hard"), + sa.Column("question", sa.Text, nullable=False, comment="题目文本"), + sa.Column("options", sa.JSON, nullable=False, comment="选项数组"), + sa.Column("correct_index", sa.Integer, nullable=False, comment="正确答案索引"), + sa.Column("explanation", sa.Text, nullable=True, comment="答案解析"), + sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true"), + comment="是否启用"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("idx_quiz_q_type", "quiz_questions", ["type"]) + op.create_index("idx_quiz_q_category", "quiz_questions", ["category"]) + op.create_index("idx_quiz_q_active", "quiz_questions", ["is_active"]) + + # ===================================================================== + # 6. quiz_answers — 答题记录 + # ===================================================================== + op.create_table( + "quiz_answers", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("employee_id", sa.String(64), nullable=False, comment="员工ID"), + sa.Column("conversation_id", sa.String(36), nullable=True, comment="关联会话ID"), + sa.Column("question_id", sa.String(36), nullable=False, comment="题目ID"), + sa.Column("selected_index", sa.Integer, nullable=False, comment="选择的答案索引"), + sa.Column("is_correct", sa.Boolean, nullable=False, comment="是否答对"), + sa.Column("points_earned", sa.Integer, nullable=False, server_default="0", + comment="获得积分"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("idx_quiz_a_employee", "quiz_answers", ["employee_id"]) + op.create_index("idx_quiz_a_conversation", "quiz_answers", ["conversation_id"]) + op.create_index("idx_quiz_a_created", "quiz_answers", ["created_at"]) + + # ===================================================================== + # 7. employee_points — 员工积分账户 + # ===================================================================== + op.create_table( + "employee_points", + sa.Column("employee_id", sa.String(64), primary_key=True), + sa.Column("total_points", sa.Integer, nullable=False, server_default="0", + comment="累计积分"), + sa.Column("answered_count", sa.Integer, nullable=False, server_default="0", + comment="答题总数"), + sa.Column("correct_count", sa.Integer, nullable=False, server_default="0", + comment="答对总数"), + sa.Column("level", sa.String(20), nullable=False, server_default="IT小白", + comment="当前等级"), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + # 删除新增表(逆序) + op.drop_table("employee_points") + op.drop_table("quiz_answers") + op.drop_table("quiz_questions") + op.drop_table("diagnostic_reports") + op.drop_table("diagnostic_dispatches") + op.drop_table("diagnostic_templates") + + # 删除Conversation表新增索引 + op.drop_index("idx_conversations_info_locked", "conversations") + op.drop_index("idx_conversations_queue_priority", "conversations") + + # 删除Conversation表新增字段 + op.drop_column("conversations", "reference_conversation_id") + op.drop_column("conversations", "resolve_summary") + op.drop_column("conversations", "resolved_method") + op.drop_column("conversations", "resolved_by") + op.drop_column("conversations", "info_locked") + op.drop_column("conversations", "queue_priority") diff --git a/backend/app/api/approval.py b/backend/app/api/approval.py index 0b3bc50..40ccd21 100644 --- a/backend/app/api/approval.py +++ b/backend/app/api/approval.py @@ -209,35 +209,31 @@ APPROVAL_TEMPLATES: dict[str, dict] = { # 说明:覆盖 12 种审批类型、18 个审批流程的关键词,用于快速过滤非审批消息, # 避免 Dify 被每条消息调用。当 Dify 不可用时降级为关键词匹配。 -# 审批关键词预过滤列表(覆盖 12 种审批类型,用于快速过滤非审批消息) +# 审批关键词预过滤列表(v2.0 收窄版 — 2026-07-13) +# ------------------------------------------------------------ +# 设计原则:只保留「强意图词」和「复合专有词」,移除高频泛化词。 +# +# 改造前 ~40 词(含"设备""电脑""邮箱""权限""软件""报修"等), +# 几乎覆盖所有 IT 消息 → 预过滤命中率 ~60% → 大量无效 Dify 调用。 +# +# 改造后策略: +# - 强意图动词:用户明确表达"申请/提交"意愿时才触发 +# - 复合专有词:仅在审批场景出现,不会在普通 IT 咨询中出现 +# - 移除的泛化词(设备/电脑/邮箱/权限/软件/报修/变更/会议室等) +# 改由 Dify 意图识别内部判断,不作为预过滤触发条件 +# +# 预期效果:预过滤命中率从 ~60% 降至 ~15%,减少 75% 无效 Dify 调用 APPROVAL_PREFILTER_KEYWORDS: list[str] = [ - # 通用 - "申请", "资源", - # 设备申请 - "设备", "电脑", "笔记本", "显示器", "资产领用", "资产借用", "资产升级", - # 账号权限申请 - "VPN", "vpn", "账号", "邮箱", "外联", "权限", - # 软件服务申请 - "软件", "业务系统", - # 资产处置申请 - "外修", "报废", "退还", - # 办公用品申请 - "办公用品", "超额", - # 会议室故障报修 - "会议室", "故障报修", "报修", - # 企业应用管理 - "企业应用", "应用管理", "应用开通", - # 资产变更确认 - "资产变更", "变更确认", "变更", - # 终端设备网络准入 - "网络准入", "终端准入", "准入申请", - # 活动与会议技术支持 - "活动支持", "会议支持", "活动技术", - # 员工IT支持与故障报修 - "IT支持", "故障报修", - # 公共邮箱账号申请 - "公共邮箱", "公共账号", "共享邮箱", - # 自备电脑补贴(BYOD)— 确保 BYOD 相关消息能被预过滤捕获 + # === 强意图动词/短语(用户明确表达申请意愿)=== + "申请", "审批", "提交", "表单", "走流程", + "帮我申请", "我要申请", "需要申请", "想申请", + # === 复合专有词(仅在审批场景出现,日常 IT 咨询不会用)=== + "资产领用", "资产借用", "资产升级", "资产变更", "资产处置", + "网络准入", "终端准入", + "办公用品", "超额领用", + "企业应用管理", "应用开通", + "公共邮箱", "共享邮箱", "公共账号", + "故障报修", "IT支持", "自备电脑", "电脑补贴", "BYOD", "byod", ] @@ -955,10 +951,12 @@ async def get_approval_keywords(): # ============================================================================= def _keyword_prefilter(text: str) -> bool: - """关键词预过滤:检查文本是否包含审批相关关键词。 + """关键词预过滤:检查文本是否包含审批相关关键词(v2.0 收窄版)。 - 合并 APPROVAL_TEMPLATES 的 keywords 和 APPROVAL_PREFILTER_KEYWORDS, - 只要命中任意一个关键词即返回 True,未命中返回 False。 + v2.0 变更(2026-07-13): + - 不再合并 APPROVAL_TEMPLATES 的 keywords(包含"借用""升级""外联"等泛化词) + - 仅使用 APPROVAL_PREFILTER_KEYWORDS(强意图词 + 复合专有词) + - 模板 keywords 仍保留在 KEYWORD_TO_APPROVAL_TYPE 中,仅用于 Dify 不可用时的降级兜底 Args: text: 用户消息文本 @@ -969,11 +967,8 @@ def _keyword_prefilter(text: str) -> bool: if not text: return False lower_text = text.lower() - # 合并 APPROVAL_TEMPLATES 的 keywords 和预定义关键词 - all_keywords: set[str] = set(APPROVAL_PREFILTER_KEYWORDS) - for template in APPROVAL_TEMPLATES.values(): - all_keywords.update(template.get("keywords", [])) - return any(kw.lower() in lower_text for kw in all_keywords) + # 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]]: diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py index 3f28f85..dd78c79 100644 --- a/backend/app/api/conversations.py +++ b/backend/app/api/conversations.py @@ -17,6 +17,7 @@ from typing import Optional from uuid import UUID from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -307,92 +308,55 @@ async def assign_conversation( # -------------------------------------------------------------------------- -# POST /api/conversations/{id}/resolve — 结单 +# POST /api/conversations/{id}/resolve — 坐席发起结单(触发员工确认) # -------------------------------------------------------------------------- +# 决策 G1:坐席发起→员工确认(员工有权否决) +# 流程变更:原直接 resolved → 新流程 pending_close → 员工确认 → resolved +class AgentResolveRequest(BaseModel): + """坐席结单请求体。""" + resolve_summary: str = Field(..., description="结单摘要(问题类型+根因+解决方式)") + + @router.post("/conversations/{conversation_id}/resolve") @require_permission("conversation", "update", "own") async def resolve_conversation( conversation_id: str, + body: AgentResolveRequest, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): - """结单。 + """坐席发起结单(触发员工确认流程)。 - 坐席点击"结单"按钮时调用,将会话状态改为 resolved。 - 结单完成后异步触发知识建议生成(通道 A 全链路闭环)。 + 改造说明(决策 G1): + - 原逻辑:直接将会话状态改为 resolved + - 新逻辑:状态改为 pending_close → 推送确认卡片给员工 + - 员工确认后 → resolved + - 员工拒绝 → 恢复 serving + - 5分钟超时 → 自动 resolved 权限控制:只有主责坐席(assigned_agent_id)才能结单。 - 协作坐席和其他坐席不能结单。 Args: conversation_id: 会话ID + body: 结单请求体(含 resolve_summary) db: 数据库会话 current_agent: 当前坐席(认证依赖注入) Returns: - Dict: 统一响应格式,包含更新后的会话信息 + Dict: 统一响应格式,包含更新后的会话信息(状态为 pending_close) """ - session_service = SessionService(db) + from app.services.closing_service import ClosingService - # 先查询会话,验证主责坐席身份 - from sqlalchemy import select as _select - from app.models.conversation import Conversation as _Conversation - stmt = _select(_Conversation).where(_Conversation.id == conversation_id) - result = await db.execute(stmt) - conv = result.scalars().first() - if not conv: - raise AppException(3003, "会话不存在") - if conv.assigned_agent_id != current_agent.user_id: - raise AppException(3027, "只有主责坐席才能结单") - - conversation = await session_service.resolve_conversation(conversation_id) + closing_service = ClosingService(db) + conversation = await closing_service.agent_initiate_resolve( + conversation_id=conversation_id, + agent_id=current_agent.user_id, + resolve_summary=body.resolve_summary, + ) + await db.commit() response_data = ConversationResponse.model_validate(conversation).model_dump() - - # ── 任务1(P0):会话关闭→异步触发知识建议生成 ── - # 在结单响应返回后,异步调用 Dify 生成知识迭代建议。 - # 使用 FastAPI BackgroundTasks 确保不阻塞结单响应。 - try: - from fastapi import BackgroundTasks - import asyncio as _asyncio - - async def _trigger_knowledge_suggestion(): - """异步生成知识建议的后台任务(独立 db session)。""" - from app.database import _get_session_factory - from app.services.knowledge_iteration_service import KnowledgeIterationService - - factory = _get_session_factory() - async with factory() as bg_db: - try: - knowledge_service = KnowledgeIterationService() - suggestion = await knowledge_service.generate_knowledge_suggestion( - db=bg_db, - source_type="conversation", - source_data=[str(conversation_id)], - reason=f"会话'{conversation_id}'已结单,自动生成知识迭代建议", - ) - if suggestion: - bg_db.add(suggestion) - await bg_db.commit() - logger.info( - f"会话关闭→知识建议已生成: conv_id={conversation_id}, " - f"suggestion_id={suggestion.id}, type={suggestion.suggestion_type}" - ) - else: - logger.info( - f"会话关闭→无知识建议生成(Dify不可用或无需建议): " - f"conv_id={conversation_id}" - ) - except Exception as e: - logger.error(f"会话关闭→知识建议生成失败: conv_id={conversation_id}, error={e}") - - # 创建后台任务(不阻塞结单响应) - _asyncio.ensure_future(_trigger_knowledge_suggestion()) - logger.info(f"会话结单完成,已触发异步知识建议生成: conv_id={conversation_id}") - except Exception as e: - # 知识建议生成失败不影响结单主流程 - logger.warning(f"触发异步知识建议生成失败(不影响结单): {e}") - + response_data["message"] = "结单请求已发送,等待员工确认(5分钟内未响应将自动关闭)。" return success_response(data=response_data) diff --git a/backend/app/api/exclusion_rules.py b/backend/app/api/exclusion_rules.py new file mode 100644 index 0000000..2573bf1 --- /dev/null +++ b/backend/app/api/exclusion_rules.py @@ -0,0 +1,381 @@ +# ============================================================================= +# 企微IT智能服务台 — 代答排除管理 API +# ============================================================================= +# 说明:代答排除规则的管理后台接口,共 8 个端点。 +# 路由前缀:/admin/exclusion-rules(router.py 已注册) +# 认证:@require_admin +# +# 1. GET /admin/exclusion-rules — 规则列表(分页+筛选) +# 2. POST /admin/exclusion-rules — 新建规则 +# 3. GET /admin/exclusion-rules/{id} — 规则详情 +# 4. PUT /admin/exclusion-rules/{id} — 编辑规则 +# 5. DELETE /admin/exclusion-rules/{id} — 删除规则 +# 6. POST /admin/exclusion-rules/{id}/toggle — 启用/停用 +# 7. POST /admin/exclusion-rules/test — 测试匹配 +# 8. GET /admin/exclusion-rules/stats — 统计概要 +# ============================================================================= + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import or_, select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import get_current_user, require_admin, UserInfo +from app.models.exclusion_rule import ExclusionRule +from app.schemas.exclusion import ( + ExclusionRuleCreate, + ExclusionRuleUpdate, + ExclusionRuleToggle, + ExclusionTestRequest, + ExclusionRuleResponse, + ExclusionTestResponse, + ExclusionStatsResponse, +) +from app.services.exclusion_service import get_exclusion_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================= +# 工具函数 +# ============================================================================= + +def _rule_to_response(rule: ExclusionRule) -> dict: + """将 ORM 对象转为响应字典。""" + return { + "id": rule.id, + "rule_name": rule.rule_name, + "rule_description": rule.rule_description, + "priority": rule.priority, + "match_type": rule.match_type, + "match_condition": rule.match_condition, + "match_scope": rule.match_scope or [], + "action_type": rule.action_type, + "transfer_message": rule.transfer_message, + "status": rule.status, + "hit_count": rule.hit_count, + "created_by": rule.created_by, + "created_at": rule.created_at.isoformat() if rule.created_at else None, + "updated_at": rule.updated_at.isoformat() if rule.updated_at else None, + } + + +# ============================================================================= +# 1. 规则列表(分页+筛选) +# ============================================================================= + +@router.get("") +@require_admin +async def list_rules( + status: Optional[str] = Query(default=None, description="状态筛选:enabled/disabled"), + match_type: Optional[str] = Query(default=None, description="匹配方式筛选"), + priority: Optional[str] = Query(default=None, description="优先级筛选:P0/P1/P2/P3"), + keyword: Optional[str] = Query(default=None, description="关键词搜索(规则名称/描述)"), + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, description="每页数量"), + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取排除规则列表(分页+筛选)。 + + 支持按状态、匹配方式、优先级筛选,以及关键词搜索规则名称和描述。 + """ + conditions = [] + if status: + conditions.append(ExclusionRule.status == status) + if match_type: + conditions.append(ExclusionRule.match_type == match_type) + if priority: + conditions.append(ExclusionRule.priority == priority) + if keyword: + conditions.append( + or_( + ExclusionRule.rule_name.ilike(f"%{keyword}%"), + ExclusionRule.rule_description.ilike(f"%{keyword}%"), + ) + ) + + # 统计总数 + count_stmt = select(func.count()).select_from(ExclusionRule) + if conditions: + count_stmt = count_stmt.where(and_(*conditions)) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页查询 + stmt = select(ExclusionRule).order_by( + ExclusionRule.priority, + ExclusionRule.created_at.desc(), + ) + if conditions: + stmt = stmt.where(and_(*conditions)) + offset = (page - 1) * page_size + stmt = stmt.offset(offset).limit(page_size) + + result = await db.execute(stmt) + rules = result.scalars().all() + + return { + "code": 0, + "message": "success", + "data": { + "total": total, + "items": [_rule_to_response(r) for r in rules], + }, + } + + +# ============================================================================= +# 2. 新建规则 +# ============================================================================= + +@router.post("") +@require_admin +async def create_rule( + body: ExclusionRuleCreate, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """新建排除规则。""" + # 检查规则名称唯一性 + existing = await db.execute( + select(ExclusionRule).where(ExclusionRule.rule_name == body.rule_name) + ) + if existing.scalar_one_or_none(): + return {"code": 400, "message": f"规则名称已存在: {body.rule_name}", "data": None} + + rule = ExclusionRule( + rule_name=body.rule_name, + rule_description=body.rule_description, + priority=body.priority, + match_type=body.match_type, + match_condition=body.match_condition, + match_scope=body.match_scope, + action_type=body.action_type, + transfer_message=body.transfer_message, + status="enabled", + hit_count=0, + created_by=current_user.employee_id, + ) + db.add(rule) + await db.commit() + await db.refresh(rule) + + logger.info("排除规则已创建: %s, by=%s", rule.rule_name, current_user.employee_id) + + return { + "code": 0, + "message": "规则创建成功", + "data": _rule_to_response(rule), + } + + +# ============================================================================= +# 3. 测试匹配(必须在 /{rule_id} 之前注册,避免路由冲突) +# ============================================================================= + +@router.post("/test") +@require_admin +async def test_match( + body: ExclusionTestRequest, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """测试消息匹配排除规则。 + + 可指定 rule_id 测试单条规则,不指定则测试所有启用规则。 + 测试不会记录日志、不会更新 hit_count。 + """ + service = get_exclusion_service() + result = await service.test_match( + db=db, + message=body.message, + rule_id=body.rule_id, + ) + + return { + "code": 0, + "message": "success", + "data": { + "matched": result.matched, + "matched_detail": result.matched_detail if result.matched else None, + "rule_name": result.rule_name if result.matched else None, + "action_type": result.action_type if result.matched else None, + }, + } + + +# ============================================================================= +# 4. 统计概要(必须在 /{rule_id} 之前注册,避免路由冲突) +# ============================================================================= + +@router.get("/stats") +@require_admin +async def get_stats( + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取排除规则统计概要。""" + service = get_exclusion_service() + stats = await service.get_stats(db=db) + + return { + "code": 0, + "message": "success", + "data": stats, + } + + +# ============================================================================= +# 5. 规则详情 +# 注意:/test 和 /stats 必须在此路由之前注册,否则会被 /{rule_id} 匹配 +# ============================================================================= + +@router.get("/{rule_id}") +@require_admin +async def get_rule( + rule_id: str, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取规则详情。""" + result = await db.execute( + select(ExclusionRule).where(ExclusionRule.id == rule_id) + ) + rule = result.scalar_one_or_none() + if not rule: + return {"code": 404, "message": "规则不存在", "data": None} + + return { + "code": 0, + "message": "success", + "data": _rule_to_response(rule), + } + + +# ============================================================================= +# 6. 编辑规则 +# ============================================================================= + +@router.put("/{rule_id}") +@require_admin +async def update_rule( + rule_id: str, + body: ExclusionRuleUpdate, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """编辑排除规则。""" + result = await db.execute( + select(ExclusionRule).where(ExclusionRule.id == rule_id) + ) + rule = result.scalar_one_or_none() + if not rule: + return {"code": 404, "message": "规则不存在", "data": None} + + # 如果修改了规则名称,检查唯一性 + if body.rule_name and body.rule_name != rule.rule_name: + existing = await db.execute( + select(ExclusionRule).where(ExclusionRule.rule_name == body.rule_name) + ) + if existing.scalar_one_or_none(): + return {"code": 400, "message": f"规则名称已存在: {body.rule_name}", "data": None} + + # 更新字段(仅更新传入的字段) + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(rule, field, value) + + rule.updated_at = datetime.now() + await db.commit() + await db.refresh(rule) + + logger.info("排除规则已更新: %s, by=%s", rule.rule_name, current_user.employee_id) + + return { + "code": 0, + "message": "规则更新成功", + "data": _rule_to_response(rule), + } + + +# ============================================================================= +# 7. 删除规则 +# ============================================================================= + +@router.delete("/{rule_id}") +@require_admin +async def delete_rule( + rule_id: str, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """删除排除规则。""" + result = await db.execute( + select(ExclusionRule).where(ExclusionRule.id == rule_id) + ) + rule = result.scalar_one_or_none() + if not rule: + return {"code": 404, "message": "规则不存在", "data": None} + + rule_name = rule.rule_name + await db.delete(rule) + await db.commit() + + logger.info("排除规则已删除: %s, by=%s", rule_name, current_user.employee_id) + + return { + "code": 0, + "message": "删除成功", + "data": None, + } + + +# ============================================================================= +# 8. 启用/停用 +# ============================================================================= + +@router.post("/{rule_id}/toggle") +@require_admin +async def toggle_rule( + rule_id: str, + body: ExclusionRuleToggle, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """启用/停用排除规则。""" + result = await db.execute( + select(ExclusionRule).where(ExclusionRule.id == rule_id) + ) + rule = result.scalar_one_or_none() + if not rule: + return {"code": 404, "message": "规则不存在", "data": None} + + if body.status not in ("enabled", "disabled"): + return {"code": 400, "message": "无效状态,仅支持 enabled/disabled", "data": None} + + rule.status = body.status + rule.updated_at = datetime.now() + await db.commit() + await db.refresh(rule) + + logger.info( + "排除规则状态切换: %s → %s, by=%s", + rule.rule_name, rule.status, current_user.employee_id, + ) + + return { + "code": 0, + "message": f"规则已{'启用' if rule.status == 'enabled' else '停用'}", + "data": _rule_to_response(rule), + } + + +# 注意:/test 和 /stats 路由已在文件上方(/{rule_id} 之前)注册, +# 避免 FastAPI 路由匹配将 "test"/"stats" 误认为 rule_id。 diff --git a/backend/app/api/h5.py b/backend/app/api/h5.py index 46a29ae..85ee500 100644 --- a/backend/app/api/h5.py +++ b/backend/app/api/h5.py @@ -67,6 +67,8 @@ from app.services.ws_manager import manager as ws_manager from app.services.wecom_service import WecomService from app.services.employee_directory import get_org_directory from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response +from app.services.closing_service import ClosingService +from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -921,12 +923,15 @@ async def h5_send_message( # 为什么:AI 推理(Dify)慢(3~15s),放后台经 WS 流式推回, # 发送接口瞬时返回,前端不再卡"发送中" # 约束:后台任务使用独立 DB session,且需单 worker(见 h5_ai_task.py) + # v2.1(Phase 4):传递 msg_type 和 media_url,支持图片消息 VisionService 分析 asyncio.create_task( process_h5_ai_reply( conversation_id=str(conversation.id), employee_id=employee_id, content=content, dify_conversation_id=conversation.dify_conversation_id, + msg_type=msg_type, + media_url=media_url, ) ) @@ -1130,21 +1135,43 @@ async def shake( "请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~" ) + # ================================================================ + # 决策 E2/E3:紧急关键词直通检测 + # ================================================================ + # 检查最近消息是否包含紧急关键词 + # 如果命中 → 绕过3轮AI互动限制,直接进入排队/分配(紧急直通) + from app.services.triage_service import URGENCY_HIGH_KEYWORDS + + last_msg_summary = (conversation.last_message_summary or "").lower() + is_emergency = any(kw in last_msg_summary for kw in URGENCY_HIGH_KEYWORDS) + # 前置校验:必须满足 AI 实质性回复 >= 3 次才能呼叫坐席 - if conversation.ai_substantive_reply_count < 3: + # 例外:紧急关键词命中时绕过此限制(决策 E2 紧急直通) + if not is_emergency and conversation.ai_substantive_reply_count < 3: raise AppException( 1003, "请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~" ) - + + # ================================================================ + # 决策 E4:未梳理提醒 + # ================================================================ + # 如果信息未锁定(info_locked=False),返回 needs_info_confirm 标记 + # 前端据此弹窗提示"完成信息梳理可进入快速通道" + needs_info_confirm = not conversation.info_locked + # 更新员工姓名 if employee_name and not conversation.employee_name: conversation.employee_name = employee_name # 设置举手标记 tags = dict(conversation.tags) if conversation.tags else {} tags["hand_raise"] = True + if is_emergency: + tags["emergency_direct_connect"] = True # 紧急直通标记 conversation.tags = tags conversation.urgency_score = max(conversation.urgency_score, 2) + if is_emergency: + conversation.urgency_score = max(conversation.urgency_score, 5) # 紧急直通设最高紧急度 conversation.last_message_at = datetime.now() conversation.updated_at = datetime.now() db.add(conversation) @@ -1215,12 +1242,15 @@ async def shake( "hand_raise": True, "assigned_agent_id": assigned_agent_id, "assign_result": assign_result, + "is_emergency": is_emergency, # 紧急直通标记 + "info_locked": conversation.info_locked, # 信息梳理状态 + "queue_priority": conversation.queue_priority, # 答题插队优先级 } }) except Exception as e: logger.warning(f"WebSocket广播失败(不阻塞流程): {e}") - logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}, assign_result={assign_result}") + logger.info(f"举手触发: employee_id={employee_id}, conv_id={conversation.id}, assign_result={assign_result}, emergency={is_emergency}") # 7. 返回会话信息和话术 conv_data = ConversationResponse.model_validate(conversation).model_dump() @@ -1230,6 +1260,9 @@ async def shake( "funny_phrase": phrase, "assign_result": assign_result, "assigned_agent_id": assigned_agent_id, + "is_emergency": is_emergency, # 紧急直通标记 + "needs_info_confirm": needs_info_confirm, # 信息未梳理提醒 + "info_locked": conversation.info_locked, # 当前信息锁定状态 } ) @@ -1367,18 +1400,22 @@ async def get_queue_status( employee_id: str = Query(..., description="员工ID"), db: AsyncSession = Depends(get_db), ): - """查询当前排队状态。 + """查询当前排队状态(三段排序版)。 - 返回当前会话的排队位置和预计等待时间。 + 排队三段排序: + 1. VIP段(is_vip=true) + 2. 已梳理段(info_locked=true) + 3. 待梳理段(info_locked=false) + + 段内排序:queue_priority DESC → urgency_score DESC → created_at ASC Args: employee_id: 员工ID Returns: - Dict: 排队状态信息 + Dict: 排队状态信息(含段位、位置、预估等待时间) """ - from sqlalchemy import select, func - from app.models.conversation import Conversation + from app.services.queue_service import get_queue_service # 1. 查找该员工的排队会话 stmt = select(Conversation).where( @@ -1390,7 +1427,7 @@ async def get_queue_status( conversation = result.scalars().first() if not conversation: - # 不在排队中,可能是已分配或无会话 + # 不在排队中 return success_response(data={ "in_queue": False, "status": None, @@ -1398,23 +1435,22 @@ async def get_queue_status( "estimated_wait_seconds": 0, }) - # 2. 计算排队位置(按创建时间排序) - count_stmt = select(func.count(Conversation.id)).where( - Conversation.status == "queued", - Conversation.created_at < conversation.created_at, - ) - count_result = await db.execute(count_stmt) - queue_position = count_result.scalar() or 0 - - # 3. 计算预计等待时间(基于平均处理时长5分钟) - estimated_wait_seconds = queue_position * 300 # 5分钟/人 + # 2. 使用 QueueService 计算三段排序位置 + queue_service = get_queue_service() + queue_info = await queue_service.calculate_queue_position(db, conversation) return success_response(data={ "in_queue": True, "status": conversation.status, - "queue_position": queue_position + 1, - "estimated_wait_seconds": estimated_wait_seconds, "conversation_id": str(conversation.id), + "queue_position": queue_info["position"], + "estimated_wait_seconds": queue_info["estimated_wait_sec"], + "segment": queue_info["segment"], + "segment_label": queue_info["segment_label"], + "ahead_count": queue_info["ahead_count"], + "queue_priority": queue_info["queue_priority"], + "info_locked": conversation.info_locked, + "is_vip": conversation.is_vip, }) @@ -1865,3 +1901,234 @@ async def h5_invite_participant( response_data = ConversationResponse.model_validate(conversation).model_dump() return success_response(data=response_data) + + +# ========================================================================== +# 关闭机制 API(决策 G1-G5) +# ========================================================================== +# 五种关闭场景的 H5 端点: +# POST /api/h5/conversations/current/resolve — 员工确认AI已解决 +# POST /api/h5/conversations/current/close — 员工主动关闭 +# POST /api/h5/conversations/current/resolve/confirm — 员工确认坐席结单 +# POST /api/h5/conversations/current/resolve/reject — 员工拒绝坐席结单 +# POST /api/h5/conversations/current/reopen — 24h内重开 +# ========================================================================== + + +class SelfResolveRequest(BaseModel): + """员工确认AI已解决请求体。""" + resolve_summary: Optional[str] = Field(None, description="解决摘要(可选)") + + +class EmployeeCloseRequest(BaseModel): + """员工主动关闭请求体。""" + close_reason: Optional[str] = Field(None, description="关闭原因(可选)") + + +class ResolveConfirmRequest(BaseModel): + """员工确认/拒绝坐席结单请求体。""" + action: str = Field(..., description="confirm=确认, reject=拒绝") + reason: Optional[str] = Field(None, description="拒绝原因(拒绝时可选)") + + +class ReopenRequest(BaseModel): + """重开会话请求体。""" + original_conversation_id: str = Field(..., description="原会话ID") + + +# -------------------------------------------------------------------------- +# POST /api/h5/conversations/current/resolve — 员工确认AI已解决 +# -------------------------------------------------------------------------- +@router.post("/h5/conversations/current/resolve") +async def h5_self_resolve( + body: SelfResolveRequest, + employee_id: str = Depends(_get_current_employee), + db: AsyncSession = Depends(get_db), +): + """员工确认AI已解决问题(AI自助场景)。 + + 触发场景: + - 对话流中"已解决"确认卡片按钮 + - AI检测到关闭关键词后推送的确认卡片 + + 状态转换:ai_handling → resolved + 关闭方:employee / 关闭方式:ai_self + + Args: + body: 请求体(可选 resolve_summary) + employee_id: 当前登录员工ID + db: 数据库会话 + + Returns: + Dict: 统一响应格式,包含已关闭的会话信息 + """ + closing_service = ClosingService(db) + conversation = await closing_service.employee_self_resolve( + employee_id=employee_id, + resolve_summary=body.resolve_summary, + ) + await db.commit() + + response_data = ConversationResponse.model_validate(conversation).model_dump() + return success_response(data=response_data) + + +# -------------------------------------------------------------------------- +# POST /api/h5/conversations/current/close — 员工主动关闭 +# -------------------------------------------------------------------------- +@router.post("/h5/conversations/current/close") +async def h5_employee_close( + body: EmployeeCloseRequest, + employee_id: str = Depends(_get_current_employee), + db: AsyncSession = Depends(get_db), +): + """员工主动关闭会话。 + + 适用场景: + - 问题自行解决,不需要AI或坐席帮助 + - 不想继续等待 + - 问题已通过其他渠道解决 + + 状态转换:任意活跃状态 → resolved + 关闭方:employee / 关闭方式:employee_initiative + + Args: + body: 请求体(可选 close_reason) + employee_id: 当前登录员工ID + db: 数据库会话 + + Returns: + Dict: 统一响应格式,包含已关闭的会话信息 + """ + closing_service = ClosingService(db) + conversation = await closing_service.employee_initiative_close( + employee_id=employee_id, + close_reason=body.close_reason, + ) + await db.commit() + + response_data = ConversationResponse.model_validate(conversation).model_dump() + return success_response(data=response_data) + + +# -------------------------------------------------------------------------- +# POST /api/h5/conversations/current/resolve/confirm — 员工确认/拒绝坐席结单 +# -------------------------------------------------------------------------- +@router.post("/h5/conversations/current/resolve/confirm") +async def h5_resolve_confirm( + body: ResolveConfirmRequest, + employee_id: str = Depends(_get_current_employee), + db: AsyncSession = Depends(get_db), +): + """员工确认或拒绝坐席的结单请求。 + + 坐席发起结单后,会话进入 pending_close 状态, + 员工通过此端点确认或拒绝。 + + - confirm: pending_close → resolved(坐席结单+员工确认) + - reject: pending_close → serving(恢复服务) + - 5分钟内不响应:系统自动关闭 + + Args: + body: 请求体(action=confirm/reject, reason=拒绝原因) + employee_id: 当前登录员工ID + db: 数据库会话 + + Returns: + Dict: 统一响应格式,包含更新后的会话信息 + """ + closing_service = ClosingService(db) + + if body.action == "confirm": + conversation = await closing_service.employee_confirm_resolve(employee_id) + message = "结单确认成功,会话已关闭。" + elif body.action == "reject": + conversation = await closing_service.employee_reject_resolve( + employee_id, reason=body.reason + ) + message = "已为您恢复服务,坐席将继续处理。" + else: + raise AppException(1008, f"无效的action: {body.action},应为 confirm 或 reject") + + await db.commit() + + response_data = ConversationResponse.model_validate(conversation).model_dump() + response_data["message"] = message + return success_response(data=response_data) + + +# -------------------------------------------------------------------------- +# POST /api/h5/conversations/current/reopen — 24h内重开已关闭会话 +# -------------------------------------------------------------------------- +@router.post("/h5/conversations/current/reopen") +async def h5_reopen( + body: ReopenRequest, + employee_id: str = Depends(_get_current_employee), + db: AsyncSession = Depends(get_db), +): + """24小时内重开已关闭的会话。 + + 创建新会话并关联原会话ID,用于上下文继承。 + 新会话状态为 ai_handling,复用原会话的员工信息。 + + 限制条件: + - 原会话必须已关闭(status=resolved) + - 距离关闭不超过24小时 + - 重开后新会话关联原会话的 reference_conversation_id + + Args: + body: 请求体(original_conversation_id) + employee_id: 当前登录员工ID + db: 数据库会话 + + Returns: + Dict: 统一响应格式,包含新创建的会话信息 + """ + closing_service = ClosingService(db) + new_conversation = await closing_service.reopen_conversation( + employee_id=employee_id, + original_conversation_id=body.original_conversation_id, + ) + await db.commit() + + response_data = ConversationResponse.model_validate(new_conversation).model_dump() + response_data["is_reopen"] = True + response_data["message"] = "问题已重新接入,请描述您遇到的情况。" + return success_response(data=response_data) + + +# ========================================================================== +# GET /api/h5/it-health — IT 健康信息 +# ========================================================================== +# 说明:返回当前登录员工终端的 IT 健康信息,包括设备基本信息、 +# CPU/内存/磁盘使用率、安全检查状态、合规检查状态。 +# 数据来源:联软(设备信息) + 火绒(安全状态) + 资产服务(资产编号) +# 降级策略:联软/火绒未配置时返回 Mock 数据 +# ========================================================================== + +@router.get("/h5/it-health") +async def h5_get_it_health( + employee_id: str = Depends(_get_current_employee), + db: AsyncSession = Depends(get_db), +): + """获取当前员工终端的 IT 健康信息。 + + 从联软、火绒、资产服务聚合数据,返回设备信息和安全状态。 + 如果联软/火绒集成未配置,返回 Mock 降级数据。 + + Args: + employee_id: 员工企微 UserID(通过认证依赖注入) + db: 数据库会话(读取集成配置) + + Returns: + Dict: 统一响应格式,包含: + - current_device: 当前设备信息(设备名/IP/MAC/OS/CPU/内存/磁盘/安全检查) + - other_devices: 其他设备列表 + - data_source: "real"(真实数据)或 "mock"(降级数据) + - generated_at: 生成时间 + """ + from app.services.it_health_service import ITHealthService + + service = ITHealthService(db) + result = await service.get_it_health(employee_id) + return success_response(data=result) diff --git a/backend/app/api/meetingroom.py b/backend/app/api/meetingroom.py index 60a4b75..d34609b 100644 --- a/backend/app/api/meetingroom.py +++ b/backend/app/api/meetingroom.py @@ -11,12 +11,19 @@ # DELETE /itportal/meetingroom/booking/{booking_id} — 取消预定 # GET /itportal/meetingroom/booking/{booking_id}/detail — 预定详情 # GET /itportal/meetingroom/terminal/{terminal_sn}/binding — 终端绑定查询 +# POST /itportal/meetingroom/repair — 提交设备报修 +# GET /itportal/meetingroom/guides — 操作指南列表 +# GET /itportal/meetingroom/guides/{category} — 按类型获取指南 +# GET /itportal/meetingroom/terminal/{terminal_sn}/qrcode — 终端访问二维码 # ============================================================================= +import io import logging from typing import Optional +import qrcode from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -30,8 +37,11 @@ from app.schemas.meetingroom import ( BookingDetailResponse, BookingInfoResponse, BookingItem, + GuideItem, MeetingroomItem, MeetingroomListResponse, + RepairRequest, + RepairResponse, RoomStatusResponse, TerminalBindingResponse, ) @@ -429,3 +439,200 @@ async def get_terminal_binding( except Exception as e: logger.error(f"查询终端绑定异常: sn={terminal_sn}, error={e}", exc_info=True) raise AppException(1005, f"查询终端绑定失败: {str(e)}") + + +# ============================================================================= +# POST /itportal/meetingroom/repair — 提交报修 +# ============================================================================= +@router.post("/repair", response_model=None) +async def submit_repair( + body: RepairRequest, + db: AsyncSession = Depends(get_db), +): + """提交会议室设备报修。 + + 无需认证(终端公共设备,支持匿名报修)。 + 报修后自动创建IT工单会话 + 企微消息通知管理员 + WS通知坐席。 + + Args: + body: 报修请求参数 + db: 数据库会话 + + Returns: + Dict: 统一响应格式,data 含 repair_id 和 conversation_id + """ + try: + from app.services.repair_service import RepairService + + wecom_service = WecomService(settings.create_redis_client()) + service = RepairService(db, wecom_service) + + result = await service.submit_repair( + terminal_sn=body.terminal_sn, + meetingroom_id=body.meetingroom_id, + meetingroom_name=body.meetingroom_name, + device_type=body.device_type, + fault_description=body.fault_description, + reporter_name=body.reporter_name, + reporter_userid=body.reporter_userid, + ) + + return success_response(data=RepairResponse( + repair_id=result["repair_id"], + conversation_id=result["conversation_id"], + status=0, + ).model_dump()) + + except Exception as e: + logger.error(f"提交报修异常: {e}", exc_info=True) + raise AppException(3100, f"提交报修失败: {str(e)}") + + +# ============================================================================= +# GET /itportal/meetingroom/guides — 获取操作指南列表 +# ============================================================================= +@router.get("/guides", response_model=None) +async def get_guides( + category: Optional[str] = Query(None, description="设备类型过滤"), + db: AsyncSession = Depends(get_db), +): + """获取操作指南列表。 + + 无需认证(终端公共设备,访客可查看)。 + + Args: + category: 设备类型过滤(可选):projector/video_conf/aircon/phone/other + db: 数据库会话 + + Returns: + Dict: 统一响应格式,data 含 guides 列表 + """ + try: + guides = await MeetingroomService.get_guides(db, category) + + guide_items = [ + GuideItem( + id=g["id"], + category=g["category"], + title=g["title"], + brief=g["brief"], + detail_url=g["detail_url"], + icon=g["icon"], + ).model_dump() + for g in guides + ] + + return success_response(data={"guides": guide_items}) + + except Exception as e: + logger.error(f"获取操作指南异常: {e}", exc_info=True) + raise AppException(3101, f"获取操作指南失败: {str(e)}") + + +# ============================================================================= +# GET /itportal/meetingroom/guides/{category} — 按设备类型获取指南 +# ============================================================================= +@router.get("/guides/{category}", response_model=None) +async def get_guides_by_category( + category: str, + db: AsyncSession = Depends(get_db), +): + """按设备类型获取操作指南。 + + 无需认证。 + + Args: + category: 设备类型 + db: 数据库会话 + + Returns: + Dict: 统一响应格式,data 含 guides 列表 + """ + try: + guides = await MeetingroomService.get_guides(db, category) + + guide_items = [ + GuideItem( + id=g["id"], + category=g["category"], + title=g["title"], + brief=g["brief"], + detail_url=g["detail_url"], + icon=g["icon"], + ).model_dump() + for g in guides + ] + + return success_response(data={"guides": guide_items}) + + except Exception as e: + logger.error(f"按类型获取操作指南异常: category={category}, error={e}", exc_info=True) + raise AppException(3101, f"获取操作指南失败: {str(e)}") + + +# ============================================================================= +# GET /itportal/meetingroom/terminal/{terminal_sn}/qrcode — 终端访问二维码 +# ============================================================================= +# 用于 NE2005 等不支持 H5 应用的老型号终端: +# 管理员打印此二维码贴在终端上,用户用手机扫码访问终端页面 +# ============================================================================= + +# 终端页面基础 URL(从 settings 读取,默认使用生产域名) +_TERMINAL_BASE_URL = getattr(settings, 'terminal_base_url', None) or 'https://itsupport.servyou.com.cn/itterminal/' + + +@router.get("/terminal/{terminal_sn}/qrcode") +async def get_terminal_qrcode( + terminal_sn: str, + size: int = Query(300, ge=100, le=800, description="二维码图片尺寸(像素)"), +): + """生成终端访问二维码(PNG 图片)。 + + 用于 NE2005 等不支持 H5 应用的老型号终端: + - 管理员可调用此接口获取二维码图片,打印后贴在终端上 + - 用户用企业微信/微信扫码后在手机上打开终端页面 + + 无需认证(二维码内容为公开的终端页面 URL)。 + + Args: + terminal_sn: 终端序列号 + size: 二维码图片尺寸(像素),默认 300,范围 100-800 + + Returns: + StreamingResponse: PNG 图片,Content-Type: image/png + """ + try: + # 构建终端页面 URL + url = f"{_TERMINAL_BASE_URL}{terminal_sn}/" + + # 生成二维码 + qr = qrcode.QRCode( + version=None, # 自动选择版本 + error_correction=qrcode.constants.ERROR_CORRECT_M, # 中等容错 + box_size=10, + border=2, + ) + qr.add_data(url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # 转为 PNG 字节流 + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + + logger.info(f"生成终端二维码: sn={terminal_sn}, url={url}, size={size}") + + return StreamingResponse( + buf, + media_type="image/png", + headers={ + "Cache-Control": "public, max-age=3600", # 缓存 1 小时 + "X-Terminal-URL": url, # 调试用:响应头返回 URL + }, + ) + + except Exception as e: + logger.error(f"生成终端二维码异常: sn={terminal_sn}, error={e}", exc_info=True) + raise AppException(3102, f"生成二维码失败: {str(e)}") diff --git a/backend/app/api/queue.py b/backend/app/api/queue.py new file mode 100644 index 0000000..5e7cb74 --- /dev/null +++ b/backend/app/api/queue.py @@ -0,0 +1,108 @@ +# ============================================================================= +# 企微IT智能服务台 — 排队综合查询 API +# ============================================================================= +# 说明:提供排队位置、平台统计、坐席看板等综合查询接口 +# +# 路由: +# GET /api/h5/queue/status — 员工端综合排队状态 +# GET /api/agent/queue/dashboard — 坐席端排队看板数据 +# ============================================================================= + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, Header, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.conversation import Conversation +from app.services.queue_service import get_queue_service +from app.utils.response import AppException, success_response + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["queue"]) + + +# ============================================================================= +# H5 端:综合排队状态 +# ============================================================================= +@router.get("/h5/queue/status") +async def get_queue_status( + employee_id: str = Query(..., description="员工ID"), + db: AsyncSession = Depends(get_db), +): + """获取综合排队状态(排队位置+段位+平台统计+答题状态+积分)。 + + 供 H5 端 QueueWaiting.vue 组件初始化时调用。 + 包含三段排序的排队位置、平台实时统计、答题插队进度、员工积分等级。 + + Args: + employee_id: 员工企微 UserID + + Returns: + 综合状态数据 + """ + queue_service = get_queue_service() + + # 查找员工当前活跃会话(排队中或服务中) + stmt = select(Conversation).where( + Conversation.employee_id == employee_id, + Conversation.status.in_(["ai_handling", "queued", "serving", "pending_close"]), + ).order_by(Conversation.created_at.desc()) + + result = await db.execute(stmt) + conversation = result.scalars().first() + + if not conversation: + # 无活跃会话 — 返回平台统计+默认积分 + platform_stats = await queue_service.get_platform_stats(db) + points_info = await queue_service._get_employee_points(db, employee_id) + return success_response(data={ + "conversation_status": None, + "queue": { + "position": 0, + "segment": "none", + "segment_label": "无活跃会话", + "ahead_count": 0, + "estimated_wait_sec": 0, + "estimated_wait_text": "—", + "queue_priority": 0, + }, + "platform": platform_stats, + "points": points_info, + "quiz": { + "answered_in_session": 0, + "queue_priority": 0, + "max_priority": 2, + "remaining_for_next_jump": 0, + "can_jump_more": False, + }, + "info_locked": False, + }) + + # 有活跃会话 — 返回综合状态 + status = await queue_service.get_comprehensive_status(db, conversation) + return success_response(data=status) + + +# ============================================================================= +# 坐席端:排队看板 +# ============================================================================= +@router.get("/agent/queue/dashboard") +async def get_agent_queue_dashboard( + authorization: Optional[str] = Header(None, alias="Authorization"), + db: AsyncSession = Depends(get_db), +): + """获取坐席端排队看板数据。 + + 返回排队分段统计、平台统计、按三段排序的排队列表。 + 供坐席端 ConversationList.vue 的"排队等候"区段展示。 + + Returns: + 看板数据(分段统计+排队列表) + """ + queue_service = get_queue_service() + dashboard = await queue_service.get_agent_dashboard(db) + return success_response(data=dashboard) diff --git a/backend/app/api/quiz.py b/backend/app/api/quiz.py new file mode 100644 index 0000000..03fcc15 --- /dev/null +++ b/backend/app/api/quiz.py @@ -0,0 +1,141 @@ +# ============================================================================= +# 企微IT智能服务台 — 答题系统 API +# ============================================================================= +# 说明:排队等待期间的答题+积分API +# +# 路由: +# GET /api/h5/quiz/question — 获取下一道题(双模式自动选择) +# POST /api/h5/quiz/answer — 提交答案(正误+积分+插队+下一题) +# GET /api/h5/quiz/history — 答题历史记录和积分 +# ============================================================================= + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.conversation import Conversation +from app.services.quiz_service import get_quiz_service +from app.utils.response import AppException, success_response + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["quiz"]) + + +# ============================================================================= +# 请求体定义 +# ============================================================================= + +class AnswerRequest(BaseModel): + """提交答案请求体。""" + employee_id: str + question_id: str + selected_index: int + conversation_id: Optional[str] = None + + +# ============================================================================= +# GET /api/h5/quiz/question — 获取下一道题 +# ============================================================================= +@router.get("/h5/quiz/question") +async def get_quiz_question( + employee_id: str = Query(..., description="员工ID"), + conversation_id: Optional[str] = Query(None, description="当前会话ID(排队时传入)"), + db: AsyncSession = Depends(get_db), +): + """获取下一道题(双模式自动选择)。 + + 模式选择: + - 排队中且 info_locked=false → 诊断题(答案附加到会话上下文) + - 排队中且 info_locked=true → IT知识题 + - 非排队 → IT知识题 + + Args: + employee_id: 员工ID + conversation_id: 当前会话ID(可选) + + Returns: + 题目数据 + """ + quiz_service = get_quiz_service() + + # 查找当前会话 + conversation = None + if conversation_id: + result = await db.execute( + select(Conversation).where(Conversation.id == conversation_id) + ) + conversation = result.scalar_one_or_none() + + question = await quiz_service.get_next_question(db, employee_id, conversation) + return success_response(data=question) + + +# ============================================================================= +# POST /api/h5/quiz/answer — 提交答案 +# ============================================================================= +@router.post("/h5/quiz/answer") +async def submit_quiz_answer( + body: AnswerRequest, + db: AsyncSession = Depends(get_db), +): + """提交答案,返回正误+积分变化+插队效果+下一题。 + + 处理流程: + 1. 判定正误 + 2. 记录答题 + 3. 更新积分(答对+10分,跨会话累积) + 4. 更新 queue_priority(每答3题前移1位,上限2) + 5. 如果是诊断题且答对,答案附加到会话上下文 + 6. 返回下一道题 + + Args: + body: 答案请求体 + + Returns: + 答题结果+下一题 + """ + quiz_service = get_quiz_service() + + # 查找当前会话 + conversation = None + if body.conversation_id: + result = await db.execute( + select(Conversation).where(Conversation.id == body.conversation_id) + ) + conversation = result.scalar_one_or_none() + + result = await quiz_service.submit_answer( + db, body.employee_id, body.question_id, body.selected_index, conversation + ) + return success_response(data=result) + + +# ============================================================================= +# GET /api/h5/quiz/history — 答题历史记录和积分 +# ============================================================================= +@router.get("/h5/quiz/history") +async def get_quiz_history( + employee_id: str = Query(..., description="员工ID"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db), +): + """获取答题历史记录和积分信息。 + + Args: + employee_id: 员工ID + page: 页码 + page_size: 每页数量 + + Returns: + 答题历史+积分信息 + """ + quiz_service = get_quiz_service() + history = await quiz_service.get_quiz_history(db, employee_id, page, page_size) + return success_response(data=history) diff --git a/backend/app/api/quiz_admin.py b/backend/app/api/quiz_admin.py new file mode 100644 index 0000000..7df1259 --- /dev/null +++ b/backend/app/api/quiz_admin.py @@ -0,0 +1,261 @@ +# ============================================================================= +# 企微IT智能服务台 — 测验题目管理 API(管理员) +# ============================================================================= +# 说明:管理后台的测验题目审批 API,统一 /api/admin/quiz 前缀。 +# 包含 4 个端点: +# 1. POST /generate — 手动触发 Dify 生成题目 +# 2. GET /pending — 查看待审核题目列表(分页) +# 3. POST /{id}/approve — 审批通过题目(is_active → True) +# 4. DELETE /{id} — 删除质量差的题目 +# +# 权限:所有端点需要管理员权限(Depends(require_admin)) +# ============================================================================= + +import logging +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.agents import get_current_agent +from app.database import get_db +from app.models.agent import Agent +from app.models.quiz import QuizQuestion +from app.services.quiz_generation_service import get_quiz_generation_service +from app.utils.response import AppException, success_response + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/quiz", tags=["测验题目管理"]) + + +# ========================================================================== +# 权限校验依赖(复用 admin_api.py 的模式) +# ========================================================================== + +async def require_admin( + agent: Agent = Depends(get_current_agent), +) -> Agent: + """管理员权限校验:仅 role='admin' 可访问。""" + if agent.role != "admin": + raise AppException(1004, "无管理权限") + return agent + + +# ========================================================================== +# 请求体定义 +# ========================================================================== + +class GenerateRequest(BaseModel): + """手动触发生成题目请求。""" + category: str # network/vpn/email/system/printer/security/office + question_type: str = "knowledge" # knowledge / diagnostic + count: int = 5 + problem_category: Optional[str] = None # 仅 question_type=diagnostic 时使用 + + +# ========================================================================== +# 1. POST /api/admin/quiz/generate — 手动触发 AI 生成题目 +# ========================================================================== + +@router.post("/generate") +async def generate_quiz_questions( + body: GenerateRequest, + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """手动触发 AI 生成题目。 + + 生成的题目 is_active=False,需通过 /approve 端点审批后激活。 + + Args: + body: 生成请求(category, question_type, count, problem_category) + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + 生成结果摘要(成功/失败数量 + 题目列表) + """ + service = get_quiz_generation_service() + + if body.question_type == "knowledge": + result = await service.generate_knowledge_questions_batch( + db=db, + category=body.category, + count=body.count, + is_active=False, # 手动生成也需审批 + ) + elif body.question_type == "diagnostic": + if not body.problem_category: + raise AppException(1004, "diagnostic 类型必须提供 problem_category") + result = await service.generate_diagnostic_questions_batch( + db=db, + problem_category=body.problem_category, + count=body.count, + is_active=False, + ) + else: + raise AppException(1004, f"不支持的题目类型: {body.question_type}") + + await db.commit() + + logger.info( + f"管理员 {admin.name} 手动生成题目: " + f"type={body.question_type}, category={body.category}, " + f"成功={result['success_count']}" + ) + + return success_response(data=result) + + +# ========================================================================== +# 2. GET /api/admin/quiz/pending — 查看待审核题目列表 +# ========================================================================== + +@router.get("/pending") +async def list_pending_questions( + category: Optional[str] = Query(None, description="按类别筛选"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """获取待审核题目列表(is_active=False)。 + + Args: + category: 可选,按类别筛选 + page: 页码(从 1 开始) + page_size: 每页数量(1-100) + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + 分页列表 {total, page, page_size, items} + """ + # 构建查询条件 + conditions = [QuizQuestion.is_active == False] # noqa: E712 + if category: + conditions.append(QuizQuestion.category == category) + + # 总数 + total = await db.scalar( + select(func.count(QuizQuestion.id)).where(*conditions) + ) + total = total or 0 + + # 分页查询 + offset = (page - 1) * page_size + stmt = ( + select(QuizQuestion) + .where(*conditions) + .order_by(QuizQuestion.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + result = await db.execute(stmt) + questions = result.scalars().all() + + items = [_question_to_dict(q) for q in questions] + + return success_response(data={ + "total": total, + "page": page, + "page_size": page_size, + "items": items, + }) + + +# ========================================================================== +# 3. POST /api/admin/quiz/{question_id}/approve — 审批通过题目 +# ========================================================================== + +@router.post("/{question_id}/approve") +async def approve_question( + question_id: str, + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """审批通过一道待审核题目(is_active: False → True)。 + + Args: + question_id: 题目ID + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + 更新后的题目信息 + """ + result = await db.execute( + select(QuizQuestion).where(QuizQuestion.id == question_id) + ) + question = result.scalar_one_or_none() + if not question: + raise AppException(1004, "题目不存在") + + if question.is_active: + raise AppException(1004, "题目已激活,无需重复审批") + + question.is_active = True + await db.commit() + + logger.info(f"管理员 {admin.name} 审批通过题目: {question_id}") + + return success_response(data=_question_to_dict(question)) + + +# ========================================================================== +# 4. DELETE /api/admin/quiz/{question_id} — 删除题目 +# ========================================================================== + +@router.delete("/{question_id}") +async def delete_question( + question_id: str, + admin: Agent = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """删除一道题目(用于清理质量差的 AI 生成题)。 + + Args: + question_id: 题目ID + admin: 管理员(权限校验) + db: 数据库会话 + + Returns: + 删除确认 + """ + result = await db.execute( + select(QuizQuestion).where(QuizQuestion.id == question_id) + ) + question = result.scalar_one_or_none() + if not question: + raise AppException(1004, "题目不存在") + + await db.delete(question) + await db.commit() + + logger.info(f"管理员 {admin.name} 删除题目: {question_id}") + + return success_response(data={"deleted_id": question_id}) + + +# ========================================================================== +# 辅助函数 +# ========================================================================== + +def _question_to_dict(q: QuizQuestion) -> Dict[str, Any]: + """将 QuizQuestion 对象转为字典。""" + return { + "id": q.id, + "type": q.type, + "category": q.category, + "problem_category": q.problem_category, + "difficulty": q.difficulty, + "question": q.question, + "options": q.options, + "correct_index": q.correct_index, + "explanation": q.explanation, + "is_active": q.is_active, + "created_at": q.created_at.isoformat() if q.created_at else None, + } diff --git a/backend/app/api/router.py b/backend/app/api/router.py index cc1d287..b0459df 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -398,3 +398,39 @@ api_router.include_router(meetingroom_router, tags=["会议室预定"]) # DELETE /itportal/admin/terminal-bindings/{id} — 删除绑定 from app.api.admin.terminal_binding import router as terminal_binding_router api_router.include_router(terminal_binding_router, tags=["终端绑定管理"]) + +# 知识库迭代 — 分诊交互 + 代答排除 API +# H5 端分诊交互:POST /api/h5/triage/start, /step, /skip, /transfer, /complete +# 坐席端分诊看板:GET /api/agent/triage/pending, /stats, /{id}, /history, /export, +# POST /api/agent/triage/{id}/route, /{id}/exclude-options +try: + from app.api.triage import router as triage_router + api_router.include_router(triage_router, tags=["分诊交互"]) +except ImportError: + pass + +# 管理后台代答排除规则:CRUD + toggle + test + stats +try: + from app.api.exclusion_rules import router as exclusion_rules_router + api_router.include_router(exclusion_rules_router, prefix="/admin/exclusion-rules", tags=["代答排除"]) +except ImportError: + pass + +# 分层排队 + 答题系统 API +# GET /api/h5/queue/status — 员工端综合排队状态(位置+段位+统计+答题+积分) +# GET /api/agent/queue/dashboard — 坐席端排队看板(分段统计+排队列表) +# GET /api/h5/quiz/question — 获取下一道题目(双模式:诊断题/知识题) +# POST /api/h5/quiz/answer — 提交答案(判正误+积分+插队+下一题) +# GET /api/h5/quiz/history — 答题历史(分页+积分信息) +from app.api.queue import router as queue_router +from app.api.quiz import router as quiz_router +api_router.include_router(queue_router, tags=["分层排队"]) +api_router.include_router(quiz_router, tags=["答题系统"]) + +# 测验题目管理 API(管理员) +# POST /api/admin/quiz/generate — 手动触发 Dify 生成题目 +# GET /api/admin/quiz/pending — 查看待审核题目列表(分页) +# POST /api/admin/quiz/{id}/approve — 审批通过题目 +# DELETE /api/admin/quiz/{id} — 删除题目 +from app.api.quiz_admin import router as quiz_admin_router +api_router.include_router(quiz_admin_router, tags=["测验题目管理"]) diff --git a/backend/app/api/triage.py b/backend/app/api/triage.py new file mode 100644 index 0000000..a374562 --- /dev/null +++ b/backend/app/api/triage.py @@ -0,0 +1,421 @@ +# ============================================================================= +# 企微IT智能服务台 — 分诊交互 API +# ============================================================================= +# 说明:分诊交互相关接口,包括: +# H5 端(5个): +# POST /api/h5/triage/start — 发起分诊 +# POST /api/h5/triage/step — 提交步骤选择 +# POST /api/h5/triage/skip — 跳过步骤 +# POST /api/h5/triage/transfer — 转人工 +# POST /api/h5/triage/complete — 分诊完成 +# 坐席端(7个): +# GET /api/agent/triage/pending — 待分诊列表 +# GET /api/agent/triage/stats — 统计概要 +# GET /api/agent/triage/history — 历史列表 +# GET /api/agent/triage/export — 导出 xlsx +# GET /api/agent/triage/{triage_id} — 分诊详情 +# POST /api/agent/triage/{triage_id}/route — 路由操作 +# POST /api/agent/triage/{triage_id}/exclude-options — 排除选项 +# +# 注意:固定路径路由(/history, /export)必须在参数路由(/{triage_id})之前注册, +# 否则 FastAPI 会将 "history"/"export" 误匹配为 triage_id。 +# ============================================================================= + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, Query, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import get_current_user, UserInfo +from app.schemas.triage import ( + TriageStartRequest, + TriageStepRequest, + TriageSkipRequest, + TriageTransferRequest, + TriageCompleteRequest, + TriageRouteRequest, + TriageExcludeOptionsRequest, +) +from app.services.triage_service import get_triage_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# 坐席端认证依赖(延迟导入避免循环依赖) +def _get_current_agent(): + from app.api.agents import get_current_agent + return get_current_agent + + +# ============================================================================= +# H5 端接口(5个) +# ============================================================================= + +@router.post("/h5/triage/start") +async def start_triage( + body: TriageStartRequest, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """发起分诊。 + + 员工在 H5 端发送问题后,调用此接口发起 AI 分诊。 + 后端创建分诊会话,调用 Dify 分诊应用分析问题并生成分步选择题。 + 5秒超时自动转人工。 + + - **conversation_id**: 会话ID + - **question**: 员工问题文本 + """ + service = get_triage_service() + result = await service.start_triage( + db=db, + conversation_id=body.conversation_id, + question=body.question, + user_id=current_user.employee_id, + user_name=current_user.name, + user_dept=current_user.department, + ) + + if result.get("status") == "timeout": + return { + "code": 0, + "message": result.get("message", "分诊超时,已自动转人工"), + "data": result, + } + + return { + "code": 0, + "message": "success", + "data": result, + } + + +@router.post("/h5/triage/step") +async def submit_step( + body: TriageStepRequest, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """提交步骤选择。 + + 员工选择某个选项后,提交到后端记录上下文并获取下一步骤。 + + - **triage_id**: 分诊会话ID + - **step_index**: 当前步骤序号(0-based) + - **selected_label**: 选择的选项标签 + """ + service = get_triage_service() + result = await service.submit_step( + db=db, + triage_id=body.triage_id, + step_index=body.step_index, + selected_label=body.selected_label, + ) + + if "error" in result: + return {"code": 404, "message": result["error"], "data": None} + + return { + "code": 0, + "message": "success", + "data": result, + } + + +@router.post("/h5/triage/skip") +async def skip_step( + body: TriageSkipRequest, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """跳过步骤。 + + 员工跳过当前步骤,直接进入下一步。 + + - **triage_id**: 分诊会话ID + - **step_index**: 要跳过的步骤序号 + """ + service = get_triage_service() + result = await service.skip_step( + db=db, + triage_id=body.triage_id, + step_index=body.step_index, + ) + + if "error" in result: + return {"code": 404, "message": result["error"], "data": None} + + return { + "code": 0, + "message": "success", + "data": result, + } + + +@router.post("/h5/triage/transfer") +async def transfer_to_human( + body: TriageTransferRequest, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """转人工。 + + 员工主动选择转人工,后端记录上下文并将会话状态改为转人工。 + + - **triage_id**: 分诊会话ID + - **context**: 已收集的上下文列表 + """ + service = get_triage_service() + result = await service.transfer_to_human( + db=db, + triage_id=body.triage_id, + context=body.context, + ) + + if "error" in result: + return {"code": 404, "message": result["error"], "data": None} + + return { + "code": 0, + "message": "已转接人工坐席", + "data": result, + } + + +@router.post("/h5/triage/complete") +async def complete_triage( + body: TriageCompleteRequest, + current_user: UserInfo = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """分诊完成。 + + 所有步骤完成后,调用 Dify 生成最终 AI 回复。 + + - **triage_id**: 分诊会话ID + - **context**: 已收集的上下文列表 + """ + service = get_triage_service() + result = await service.complete_triage( + db=db, + triage_id=body.triage_id, + context=body.context, + ) + + if "error" in result: + return {"code": 404, "message": result["error"], "data": None} + + return { + "code": 0, + "message": "success", + "data": result, + } + + +# ============================================================================= +# 坐席端接口(7个) +# ============================================================================= + +@router.get("/agent/triage/pending") +async def list_pending( + urgency: Optional[str] = Query(default=None, description="紧急度筛选:high/medium/low"), + problem_type: Optional[str] = Query(default=None, description="问题类型筛选"), + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, description="每页数量"), + agent=Depends(_get_current_agent()), + db: AsyncSession = Depends(get_db), +): + """获取待分诊列表(按紧急度排序)。 + + 返回状态为 pending/triaging 的分诊会话,按紧急度排序(high > medium > low)。 + + **需要坐席认证。** + """ + service = get_triage_service() + result = await service.list_pending( + db=db, + urgency=urgency, + problem_type=problem_type, + page=page, + page_size=page_size, + ) + + return { + "code": 0, + "message": "success", + "data": result, + } + + +@router.get("/agent/triage/stats") +async def get_stats( + agent=Depends(_get_current_agent()), + db: AsyncSession = Depends(get_db), +): + """获取分诊看板统计概要。 + + 返回6项统计指标:待分诊数/今日已分诊/AI自答/转人工/自动审批/平均耗时。 + + **需要坐席认证。** + """ + service = get_triage_service() + result = await service.get_stats(db=db) + + return { + "code": 0, + "message": "success", + "data": result, + } + + +@router.get("/agent/triage/history") +async def get_history( + date_from: Optional[str] = Query(default=None, description="开始日期(ISO格式)"), + date_to: Optional[str] = Query(default=None, description="结束日期(ISO格式)"), + route_action: Optional[str] = Query(default=None, description="路由动作筛选"), + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, description="每页数量"), + agent=Depends(_get_current_agent()), + db: AsyncSession = Depends(get_db), +): + """获取已分诊历史列表。 + + 返回状态为 routed/skipped/timeout 的分诊会话。 + + **需要坐席认证。** + """ + service = get_triage_service() + result = await service.get_history( + db=db, + date_from=date_from, + date_to=date_to, + route_action=route_action, + page=page, + page_size=page_size, + ) + + return { + "code": 0, + "message": "success", + "data": result, + } + + +@router.get("/agent/triage/export") +async def export_sessions( + date_from: Optional[str] = Query(default=None, description="开始日期(ISO格式)"), + date_to: Optional[str] = Query(default=None, description="结束日期(ISO格式)"), + agent=Depends(_get_current_agent()), + db: AsyncSession = Depends(get_db), +): + """导出分诊记录为 xlsx。 + + 导出基础字段 + 分诊步骤详情。 + + **需要坐席认证。** + """ + service = get_triage_service() + xlsx_data = await service.export_sessions( + db=db, + date_from=date_from, + date_to=date_to, + ) + + return Response( + content=xlsx_data, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": "attachment; filename=triage_sessions.xlsx", + }, + ) + + +@router.get("/agent/triage/{triage_id}") +async def get_detail( + triage_id: str, + agent=Depends(_get_current_agent()), + db: AsyncSession = Depends(get_db), +): + """获取分诊详情。 + + 返回分诊会话的完整数据,包括用户画像、问题描述、AI分析结果、已收集上下文等。 + + **需要坐席认证。** + """ + service = get_triage_service() + result = await service.get_detail(db=db, triage_id=triage_id) + + if not result: + return {"code": 404, "message": "分诊会话不存在", "data": None} + + return { + "code": 0, + "message": "success", + "data": result, + } + + +@router.post("/agent/triage/{triage_id}/route") +async def route_session( + triage_id: str, + body: TriageRouteRequest, + agent=Depends(_get_current_agent()), + db: AsyncSession = Depends(get_db), +): + """坐席路由操作(覆盖 AI 建议)。 + + 坐席可选择4种路由动作:ai_self(AI自答)/ human(转人工)/ auto_approval(自动审批)/ skip(跳过)。 + + **需要坐席认证。** + """ + service = get_triage_service() + result = await service.route_session( + db=db, + triage_id=triage_id, + route_action=body.route_action, + route_note=body.route_note, + operator_id=agent.user_id if hasattr(agent, "user_id") else str(agent.id), + ) + + if not result: + return {"code": 404, "message": "分诊会话不存在", "data": None} + + return { + "code": 0, + "message": "路由操作成功", + "data": result, + } + + +@router.post("/agent/triage/{triage_id}/exclude-options") +async def exclude_options( + triage_id: str, + body: TriageExcludeOptionsRequest, + agent=Depends(_get_current_agent()), + db: AsyncSession = Depends(get_db), +): + """坐席排除/推荐分诊选项(WS 推送到 H5)。 + + 坐席可排除某些选项或推荐某个选项,通过 WebSocket 实时推送到 H5 端。 + + **需要坐席认证。** + """ + service = get_triage_service() + result = await service.exclude_options( + db=db, + triage_id=triage_id, + excluded_labels=body.excluded_labels, + recommended_label=body.recommended_label, + ) + + if "error" in result: + return {"code": 404, "message": result["error"], "data": None} + + return { + "code": 0, + "message": "success", + "data": result, + } diff --git a/backend/app/api/wecom_jsapi.py b/backend/app/api/wecom_jsapi.py index 25c2599..a7815d1 100644 --- a/backend/app/api/wecom_jsapi.py +++ b/backend/app/api/wecom_jsapi.py @@ -30,22 +30,26 @@ router = APIRouter() @router.get("/wecom/jsapi-config") async def get_jsapi_config( url: str = Query(..., description="当前页面 URL(不含 # 及其后)"), + with_agent_config: bool = Query( + False, description="是否同时返回 agent_config 签名(thirdPartyOpenPage 等应用身份接口需要)" + ), ): """获取企微 JS-SDK 鉴权配置。 供前端 wx.config 和 wx.agentConfig 使用。 + Args: + url: 当前页面 URL(不含 # 及其后) + with_agent_config: 是否同时返回 agent_config 签名 + - False(默认):仅返回 jsapi 签名(wx.config 用) + - True:额外返回 agent_config 签名(wx.agentConfig 用,如 thirdPartyOpenPage) + Returns: - { - "code": 0, - "data": { - "corp_id": "wwa8c87970b2011f41", - "agent_id": "1000133", - "timestamp": 1718500000, - "nonce_str": "5K8264ILTKCH...", - "signature": "f7c8e9..." - } - } + without agent_config: + { corp_id, agent_id, timestamp, nonce_str, signature } + with agent_config: + { corp_id, agent_id, timestamp, nonce_str, signature, + agent_config: { timestamp, nonce_str, signature } } """ try: wecom_service = get_shared_wecom_service() @@ -57,7 +61,7 @@ async def get_jsapi_config( timestamp = int(time.time()) nonce_str = secrets.token_hex(8) # 16 字符 - # 3. 计算签名 + # 3. 计算 jsapi 签名(wx.config 用) signature = wecom_service.generate_jsapi_signature( ticket=ticket, nonce_str=nonce_str, @@ -65,19 +69,34 @@ async def get_jsapi_config( url=url, ) - logger.info( - f"生成 JS-SDK 签名: url={url[:80]}... timestamp={timestamp}" - ) + response_data = { + "corp_id": settings.wecom_corp_id, + "agent_id": str(settings.wecom_agent_id), + "timestamp": timestamp, + "nonce_str": nonce_str, + "signature": signature, + } - return success_response( - { - "corp_id": settings.wecom_corp_id, - "agent_id": str(settings.wecom_agent_id), + logger.info(f"生成 JS-SDK 签名: url={url[:80]}... timestamp={timestamp}") + + # 4. 如果需要 agent_config 签名,额外计算 + if with_agent_config: + agent_ticket = await wecom_service.get_agent_config_ticket() + agent_nonce_str = secrets.token_hex(8) + agent_signature = wecom_service.generate_jsapi_signature( + ticket=agent_ticket, + nonce_str=agent_nonce_str, + timestamp=timestamp, + url=url, + ) + response_data["agent_config"] = { "timestamp": timestamp, - "nonce_str": nonce_str, - "signature": signature, + "nonce_str": agent_nonce_str, + "signature": agent_signature, } - ) + logger.info(f"生成 agent_config 签名: url={url[:80]}...") + + return success_response(response_data) except Exception as e: logger.error(f"生成 JS-SDK 签名失败: {e}", exc_info=True) diff --git a/backend/app/config.py b/backend/app/config.py index c3fcc4e..d0d116d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -114,6 +114,13 @@ class Settings(BaseSettings): # Dify API 请求超时(秒),在网络慢时可调大 dify_timeout: int = 30 + # Dify 原生 API 配置(绕过 dify2openai 代理,直连 Dify /v1/chat-messages) + # 为什么:dify2openai 代理存在 [object Object] 序列化 bug, + # 直连 Dify 原生 API 可绕过此问题,且响应格式更简单(answer 字段直接返回内容) + # 用法:配置后 get_structured_reply() 优先使用原生 API,未配置则回退到代理 + dify_native_base_url: str = "" # 如 http://yw-dify.dc.servyou-it.com + dify_native_api_key: str = "" # 如 app-7jkRkAzvX4QM9v9SM3P8mMEO(仅 app key,不含管道分隔格式) + # ---------------------------------------------------------------------- # AI Wingman 服务配置(Dify Agent 2 — 坐席端辅助) # ---------------------------------------------------------------------- @@ -126,12 +133,29 @@ class Settings(BaseSettings): # Wingman API 请求超时(秒) dify_wingman_timeout: int = 30 + # ---------------------------------------------------------------------- + # AI 分诊服务配置(Dify Agent 3 — 独立分诊应用) + # ---------------------------------------------------------------------- + # Dify 分诊应用 OpenAI 兼容接口地址(共用 dify2openai 代理,通过不同 API Key 区分) + dify_triage_api_url: str = "" + # Dify 分诊应用 API Key(格式:base_url|app_id|app_name) + dify_triage_api_key: str = "" + # Dify 分诊请求超时(秒),超时自动转人工 + dify_triage_timeout: int = 5 + # ---------------------------------------------------------------------- # Mock 登录配置(测试阶段使用,跳过企微 OAuth2) # ---------------------------------------------------------------------- # 是否启用 Mock 登录(默认 false,生产环境必须关闭) mock_login_enabled: bool = False + # ---------------------------------------------------------------------- + # 终端页面配置(小鱼易联会议室终端) + # ---------------------------------------------------------------------- + # 终端页面基础URL(用于生成NE2005等不支持H5的终端的二维码) + # 格式:https://域名/itterminal/ + terminal_base_url: str = "https://itsupport.servyou.com.cn/itterminal/" + # ---------------------------------------------------------------------- # 开发模式配置(本地 docker-compose.dev.yml 用) # ---------------------------------------------------------------------- diff --git a/backend/app/main.py b/backend/app/main.py index a6945db..a4e092d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -204,9 +204,22 @@ def _start_scheduler(): replace_existing=True, ) + # 注册每日测验题目生成任务(每天 03:00 执行) + from apscheduler.triggers.cron import CronTrigger + from app.tasks.quiz_generation_task import run_daily_quiz_generation + _scheduler.add_job( + run_daily_quiz_generation, + CronTrigger(hour=3, minute=0), + id='daily_quiz_generation', + name='每日测验题目自动生成', + replace_existing=True, + misfire_grace_time=3600, # 错过执行窗口1小时内仍可补执行 + ) + _scheduler.start() logger.info("✅ 超时提醒定时任务已启动(每 30 秒执行一次)") logger.info("✅ 过期处理定时任务已启动(每 1 小时执行一次,超时阈值 72 小时)") + logger.info("✅ 每日题目生成任务已启动(每天 03:00 执行)") except Exception as e: logger.error(f"启动定时任务调度器失败: {e}") @@ -352,6 +365,10 @@ async def _init_default_data(): from app.services.admin_user_service import init_super_admin await init_super_admin(db) + # 7.1 测验题库种子数据(Dify 生成 70 知识题 + 手写诊断模板) + from app.data.seed_quiz import seed_quiz_data + await seed_quiz_data(db) + # 8. (dev 模式)初始化 demo 会话,让前端有数据可发 # 真因:之前没建,前端硬编码的 conv-001 调 POST /messages 返 "会话不存在" 3003 if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true': diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e4dba85..a2a501a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -33,6 +33,12 @@ from app.models.routing_event import RoutingEvent # 路由命中统计(P1) # 会议室预定模块模型 from app.models.terminal_room_binding import TerminalRoomBinding from app.models.meetingroom_booking_snapshot import MeetingroomBookingSnapshot +from app.models.meetingroom_guide import MeetingroomGuide +from app.models.meetingroom_repair import MeetingroomRepair +# 知识库迭代 — 分诊 + 排除模块模型 +from app.models.triage_session import TriageSession +from app.models.exclusion_rule import ExclusionRule +from app.models.exclusion_log import ExclusionLog # 阶段5 自动化闭环模型 from app.models.automation import ( AutoSession, @@ -43,6 +49,9 @@ from app.models.automation import ( ActionLog, MappingCache, ) +# 智能诊断修复闭环 + 分层排队 + 答题 + 关闭机制 +from app.models.diagnostic import DiagnosticTemplate, DiagnosticDispatch, DiagnosticReport +from app.models.quiz import QuizQuestion, QuizAnswer, EmployeePoints # 所有模型类的列表,方便遍历 __all__ = [ "Conversation", @@ -71,6 +80,8 @@ __all__ = [ "RoutingEvent", "TerminalRoomBinding", "MeetingroomBookingSnapshot", + "MeetingroomGuide", + "MeetingroomRepair", "AutoSession", "AutoAction", "ApprovalTicket", @@ -78,4 +89,13 @@ __all__ = [ "RuleVersion", "ActionLog", "MappingCache", + "TriageSession", + "ExclusionRule", + "ExclusionLog", + "DiagnosticTemplate", + "DiagnosticDispatch", + "DiagnosticReport", + "QuizQuestion", + "QuizAnswer", + "EmployeePoints", ] diff --git a/backend/app/models/conversation.py b/backend/app/models/conversation.py index 7fac0be..608cf19 100644 --- a/backend/app/models/conversation.py +++ b/backend/app/models/conversation.py @@ -10,7 +10,7 @@ import uuid from datetime import datetime from typing import Any, Dict, Optional -from sqlalchemy import Boolean, DateTime, Index, Integer, JSON, String +from sqlalchemy import Boolean, DateTime, Index, Integer, JSON, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.database import Base @@ -294,9 +294,63 @@ class Conversation(Base): comment="更新时间", ) - # -------------------------------------------------------------------------- + # ====================================================================== + # P0新增:排队分层 + 答题插队 + 关闭机制 + # ====================================================================== + + # 答题插队优先级(每答3题前移1位,上限2位) + # 计算公式:queue_priority = min(quiz_answered_count // 3, 2) + # 排队时段内排序:queue_priority DESC → urgency_score DESC → created_at ASC + queue_priority: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="答题插队优先级(上限2)", + ) + + # 信息是否锁定(Dify信息梳理步骤完成 + 有效回答占比≥70%) + # 排队三段排序:VIP → info_locked=true → info_locked=false + info_locked: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + comment="信息是否锁定", + ) + + # 关闭方(谁关闭了会话) + # employee: 员工主动关闭 / agent: 坐席结单 / ai: AI自助解决 / system_timeout: 超时自动 + resolved_by: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + comment="关闭方: employee/agent/ai/system_timeout", + ) + + # 关闭方式 + # ai_self: AI自助解决 / agent_confirm: 坐席结单+员工确认 / employee_initiative: 员工主动 / auto_timeout: 超时 + resolved_method: Mapped[Optional[str]] = mapped_column( + String(30), + nullable=True, + comment="关闭方式: ai_self/agent_confirm/employee_initiative/auto_timeout", + ) + + # 结单摘要(坐席结单时填写:问题类型+根因+解决方式) + # 用于知识沉淀,调用Dify总结后生成知识条目草稿 + resolve_summary: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="结单摘要", + ) + + # 重开时关联的原会话ID(24小时内重开创建新会话,关联原会话上下文) + reference_conversation_id: Mapped[Optional[str]] = mapped_column( + String(36), + nullable=True, + comment="重开时关联的原会话ID", + ) + + # ---------------------------------------------------------------------- # 索引定义(和架构文档 DDL 严格一致) - # -------------------------------------------------------------------------- + # ---------------------------------------------------------------------- __table_args__ = ( # 按状态查询(如查询所有排队中的会话) Index("idx_conversations_status", "status"), @@ -310,6 +364,9 @@ class Conversation(Base): Index("idx_conversations_urgency_score", "urgency_score"), # 按最后消息时间倒序查询(最新消息的排前面) Index("idx_conversations_last_message_at", "last_message_at"), + # P0新增:排队分层排序支持 + Index("idx_conversations_queue_priority", "queue_priority"), + Index("idx_conversations_info_locked", "info_locked"), ) def __repr__(self) -> str: diff --git a/backend/app/models/diagnostic.py b/backend/app/models/diagnostic.py new file mode 100644 index 0000000..f236158 --- /dev/null +++ b/backend/app/models/diagnostic.py @@ -0,0 +1,232 @@ +# ============================================================================= +# 企微IT智能服务台 — 诊断相关模型 +# ============================================================================= +# 说明:包含3张表,支撑三层诊断闭环: +# 1. diagnostic_templates: 原子化诊断检查项模板库(管理员预置) +# 2. diagnostic_reports: 客户端/API采集的诊断报告存储 +# 3. diagnostic_dispatches: 诊断下发记录,追踪 dispatch→execute→analyze→resolve 闭环 +# +# 三层诊断架构: +# Layer 1 — 火绒/联软API静默采集(check_type=api, api_source=huorong/lianruan) +# Layer 2 — 客户端脚本兜底(check_type=script, script_template=PowerShell/zsh) +# Layer 3 — AI分析报告 + 修复包推送(fix_template + risk_level分级审批) +# ============================================================================= + +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import Boolean, DateTime, Index, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class DiagnosticTemplate(Base): + """诊断模板 — 原子化检查项。 + + 每条记录是一个独立的检查单元(如"ping网关""查DNS配置"), + 管理员在后台预置,AI只负责选择哪些检查项组合,不生成脚本内容。 + + Attributes: + id: 模板唯一标识(UUID) + category: 问题类别(network/vpn/email/system/printer/security/office) + name: 检查项名称(如"网关连通性检测") + check_type: 检查类型(api=服务端API采集 / script=客户端脚本采集) + api_source: API来源(huorong/lianruan),仅check_type=api时有效 + api_method: 调用的API方法名(如get_terminal_detail),仅check_type=api时有效 + script_template: PowerShell/zsh脚本模板(参数化),仅check_type=script时有效 + fix_template: 对应的修复脚本模板(可选,部分检查项有配套修复) + fix_risk_level: 修复风险等级(low/medium/high),决定审批流程 + target_condition: 触发此检查项的条件(如"dns_resolution=fail") + description: 检查项描述 + is_active: 是否启用 + """ + + __tablename__ = "diagnostic_templates" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + category: Mapped[str] = mapped_column( + String(50), nullable=False, comment="问题类别" + ) + name: Mapped[str] = mapped_column( + String(200), nullable=False, comment="检查项名称" + ) + check_type: Mapped[str] = mapped_column( + String(20), nullable=False, default="api", + comment="检查类型: api/script" + ) + api_source: Mapped[Optional[str]] = mapped_column( + String(50), nullable=True, comment="API来源: huorong/lianruan" + ) + api_method: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="调用的API方法名" + ) + script_template: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="脚本模板(PowerShell/zsh)" + ) + fix_template: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="修复脚本模板" + ) + fix_risk_level: Mapped[str] = mapped_column( + String(20), nullable=False, default="medium", + comment="修复风险等级: low/medium/high" + ) + target_condition: Mapped[Optional[str]] = mapped_column( + String(200), nullable=True, comment="触发条件" + ) + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="检查项描述" + ) + is_active: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, comment="是否启用" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=datetime.now, + comment="创建时间" + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=datetime.now, + onupdate=datetime.now, comment="更新时间" + ) + + __table_args__ = ( + Index("idx_diag_tpl_category", "category"), + Index("idx_diag_tpl_type", "check_type"), + Index("idx_diag_tpl_active", "is_active"), + ) + + def __repr__(self) -> str: + return f"" + + +class DiagnosticDispatch(Base): + """诊断下发记录 — 追踪每次诊断的完整生命周期。 + + 状态流转:dispatched → executed → analyzed → resolved + + Attributes: + id: 下发记录ID + conversation_id: 关联的会话ID + employee_id: 员工ID + template_ids: 下发的诊断模板ID列表(JSON数组) + script_content: 实际生成的脚本内容(参数化后的最终版本) + script_hash: 脚本SHA256哈希(审计追溯) + upload_token: 一次性上传token(绑定session+employee+TTL) + status: 状态(dispatched/executed/analyzed/resolved) + report_id: 关联的诊断报告ID(报告上传后填入) + fix_dispatched: 是否已下发修复包 + created_at: 下发时间 + completed_at: 完成(resolved)时间 + """ + + __tablename__ = "diagnostic_dispatches" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + conversation_id: Mapped[str] = mapped_column( + String(36), nullable=False, comment="关联会话ID" + ) + employee_id: Mapped[str] = mapped_column( + String(64), nullable=False, comment="员工ID" + ) + template_ids: Mapped[list] = mapped_column( + JSON, nullable=False, default=list, comment="诊断模板ID列表" + ) + script_content: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="生成的脚本内容" + ) + script_hash: Mapped[Optional[str]] = mapped_column( + String(64), nullable=True, comment="脚本SHA256哈希" + ) + upload_token: Mapped[Optional[str]] = mapped_column( + String(128), nullable=True, comment="一次性上传token" + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="dispatched", + comment="状态: dispatched/executed/analyzed/resolved" + ) + report_id: Mapped[Optional[str]] = mapped_column( + String(36), nullable=True, comment="关联诊断报告ID" + ) + fix_dispatched: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, comment="是否已下发修复包" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=datetime.now, + comment="下发时间" + ) + completed_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, comment="完成时间" + ) + + __table_args__ = ( + Index("idx_diag_dispatch_conv", "conversation_id"), + Index("idx_diag_dispatch_employee", "employee_id"), + Index("idx_diag_dispatch_status", "status"), + ) + + def __repr__(self) -> str: + return f"" + + +class DiagnosticReport(Base): + """诊断报告 — 存储采集到的检查结果和AI分析结论。 + + Attributes: + id: 报告ID + dispatch_id: 关联的下发记录ID + conversation_id: 关联的会话ID + employee_id: 员工ID + template_ids: 涉及的诊断模板ID列表 + report_data: 检查结果JSON([{name, status, detail, raw_output}]) + ai_analysis: AI分析结论JSON({root_cause, confidence, suggested_actions}) + status: 报告状态(pending/analyzed/resolved) + created_at: 报告上传时间 + """ + + __tablename__ = "diagnostic_reports" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + dispatch_id: Mapped[Optional[str]] = mapped_column( + String(36), nullable=True, comment="关联下发记录ID" + ) + conversation_id: Mapped[str] = mapped_column( + String(36), nullable=False, comment="关联会话ID" + ) + employee_id: Mapped[str] = mapped_column( + String(64), nullable=False, comment="员工ID" + ) + template_ids: Mapped[list] = mapped_column( + JSON, nullable=False, default=list, comment="涉及诊断模板ID列表" + ) + report_data: Mapped[Dict[str, Any]] = mapped_column( + JSON, nullable=False, default=dict, + comment="检查结果: [{name, status(pass/fail/warn/pending), detail, raw_output}]" + ) + ai_analysis: Mapped[Optional[Dict[str, Any]]] = mapped_column( + JSON, nullable=True, + comment="AI分析: {root_cause, confidence, severity, suggested_actions}" + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="pending", + comment="报告状态: pending/analyzed/resolved" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=datetime.now, + comment="报告上传时间" + ) + + __table_args__ = ( + Index("idx_diag_report_conv", "conversation_id"), + Index("idx_diag_report_employee", "employee_id"), + Index("idx_diag_report_status", "status"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/exclusion_log.py b/backend/app/models/exclusion_log.py new file mode 100644 index 0000000..532df3c --- /dev/null +++ b/backend/app/models/exclusion_log.py @@ -0,0 +1,120 @@ +# ============================================================================= +# 企微IT智能服务台 — 排除命中日志模型 +# ============================================================================= +# 说明:对应数据库 exclusion_logs 表 +# 每次排除规则命中时记录一条日志,用于审计和统计。 +# ============================================================================= + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, Index, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class ExclusionLog(Base): + """排除命中日志模型 — 对应 exclusion_logs 表。 + + 每次排除规则命中时记录一条日志,用于审计追踪和统计分析。 + + Attributes: + id: 日志ID(UUID) + rule_id: 关联的规则ID + rule_name: 规则名称(冗余,防止规则删除后日志丢失名称) + conversation_id: 会话ID + user_id: 员工ID + message_content: 触发命中的消息内容 + match_type: 匹配方式 + matched_detail: 命中详情(命中的关键词/正则/意图/分类) + action_type: 执行的动作类型 + action_result: 执行结果(success/failed) + created_at: 创建时间 + """ + + __tablename__ = "exclusion_logs" + + # 主键 + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: str(uuid.uuid4()), + ) + + # 规则关联 + rule_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + comment="关联的规则ID", + ) + rule_name: Mapped[Optional[str]] = mapped_column( + String(200), + nullable=True, + comment="规则名称(冗余存储)", + ) + + # 会话信息 + conversation_id: Mapped[Optional[str]] = mapped_column( + String(36), + nullable=True, + comment="会话ID", + ) + user_id: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="员工ID", + ) + + # 命中详情 + message_content: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="触发命中的消息内容", + ) + match_type: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + comment="匹配方式", + ) + matched_detail: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="命中详情", + ) + + # 执行结果 + action_type: Mapped[Optional[str]] = mapped_column( + String(50), + nullable=True, + comment="执行的动作类型", + ) + action_result: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="success", + comment="执行结果:success/failed", + ) + + # 时间戳 + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=datetime.now, + comment="创建时间", + ) + + # 索引 + __table_args__ = ( + Index("idx_exclusion_logs_rule", "rule_id"), + Index("idx_exclusion_logs_created", "created_at"), + Index("idx_exclusion_logs_conversation", "conversation_id"), + ) + + def __repr__(self) -> str: + """排除日志对象的字符串表示。""" + return ( + f"" + ) diff --git a/backend/app/models/exclusion_rule.py b/backend/app/models/exclusion_rule.py new file mode 100644 index 0000000..7b5560b --- /dev/null +++ b/backend/app/models/exclusion_rule.py @@ -0,0 +1,146 @@ +# ============================================================================= +# 企微IT智能服务台 — 代答排除规则模型 +# ============================================================================= +# 说明:对应数据库 exclusion_rules 表 +# 存储代答排除规则,AI回复前按优先级依次检查,命中则执行对应动作。 +# ============================================================================= + +import uuid +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import DateTime, Index, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class ExclusionRule(Base): + """代答排除规则模型 — 对应 exclusion_rules 表。 + + 存储排除规则配置,支持4种匹配方式(关键词/正则/意图/分类), + 命中后执行4种动作(转人工/转人工+上下文/仅提示/静默转人工)。 + + Attributes: + id: 规则ID(UUID) + rule_name: 规则名称(唯一) + rule_description: 规则描述 + priority: 优先级(P0/P1/P2/P3) + match_type: 匹配方式(keyword/regex/intent/category) + match_condition: 匹配条件(关键词列表/正则表达式/意图ID列表/分类名称列表) + match_scope: 匹配范围(JSON数组,如 ["ai_auto_reply"]) + action_type: 命中后动作类型 + transfer_message: 转人工提示语 + status: 状态(enabled/disabled) + hit_count: 命中次数 + created_by: 创建人ID + created_at: 创建时间 + updated_at: 更新时间 + """ + + __tablename__ = "exclusion_rules" + + # 主键 + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: str(uuid.uuid4()), + ) + + # 规则基本信息 + rule_name: Mapped[str] = mapped_column( + String(200), + nullable=False, + unique=True, + comment="规则名称(唯一)", + ) + rule_description: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="规则描述", + ) + priority: Mapped[str] = mapped_column( + String(5), + nullable=False, + default="P2", + comment="优先级:P0/P1/P2/P3", + ) + + # 匹配配置 + match_type: Mapped[str] = mapped_column( + String(20), + nullable=False, + comment="匹配方式:keyword/regex/intent/category", + ) + match_condition: Mapped[str] = mapped_column( + Text, + nullable=False, + comment="匹配条件:关键词列表/正则/意图ID列表/分类名称列表", + ) + match_scope: Mapped[List[str]] = mapped_column( + JSON, + nullable=False, + default=lambda: ["ai_auto_reply"], + comment="匹配范围", + ) + + # 命中后动作 + action_type: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="transfer_human", + comment="命中后动作:transfer_human/transfer_human_with_context/prompt_transfer/silent_transfer", + ) + transfer_message: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="转人工提示语", + ) + + # 状态 + status: Mapped[str] = mapped_column( + String(10), + nullable=False, + default="enabled", + comment="状态:enabled/disabled", + ) + hit_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="命中次数", + ) + + # 审计 + created_by: Mapped[str] = mapped_column( + String(100), + nullable=False, + comment="创建人ID", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=datetime.now, + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=datetime.now, + onupdate=datetime.now, + comment="更新时间", + ) + + # 索引 + __table_args__ = ( + Index("idx_exclusion_rules_status", "status"), + Index("idx_exclusion_rules_priority", "priority"), + Index("idx_exclusion_rules_match_type", "match_type"), + ) + + def __repr__(self) -> str: + """排除规则对象的字符串表示。""" + return ( + f"" + ) diff --git a/backend/app/models/meetingroom_guide.py b/backend/app/models/meetingroom_guide.py new file mode 100644 index 0000000..56cbd36 --- /dev/null +++ b/backend/app/models/meetingroom_guide.py @@ -0,0 +1,59 @@ +# ============================================================================= +# 企微IT智能服务台 — 会议室操作指南模型 +# ============================================================================= +# 说明:存储会议室设备操作指南数据 +# 终端大屏展示简要步骤 + 二维码指向详细文档 +# ============================================================================= + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class MeetingroomGuide(Base): + """会议室操作指南模型。 + + 按设备类型分类,每条指南包含终端大屏展示的简要说明 + 和二维码指向的详细文档URL。 + + Attributes: + id: 自增主键 + category: 设备类型(projector/video_conf/aircon/phone/other) + title: 指南标题(如"投影仪使用指南") + brief: 简要操作步骤(终端大屏展示,支持多行文本) + detail_url: 详细文档URL(二维码指向的链接) + icon: 图标emoji(如"📽️") + sort_order: 排序序号(越小越靠前) + is_active: 是否启用 + created_at: 创建时间 + updated_at: 更新时间 + """ + + __tablename__ = "meetingroom_guide" + + # 自增主键 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 设备类型分类 + category: Mapped[str] = mapped_column(String(50), index=True, nullable=False, comment="设备类型") + # 指南标题 + title: Mapped[str] = mapped_column(String(100), nullable=False, comment="指南标题") + # 简要操作步骤(终端大屏展示) + brief: Mapped[str] = mapped_column(Text, nullable=False, default="", comment="简要操作步骤") + # 详细文档URL(二维码指向) + detail_url: Mapped[str] = mapped_column(String(500), nullable=False, default="", comment="详细文档URL") + # 图标emoji + icon: Mapped[str] = mapped_column(String(50), nullable=False, default="📋", comment="图标emoji") + # 排序序号 + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序序号") + # 是否启用 + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, comment="是否启用") + # 创建时间 + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + # 更新时间 + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/meetingroom_repair.py b/backend/app/models/meetingroom_repair.py new file mode 100644 index 0000000..f6b245a --- /dev/null +++ b/backend/app/models/meetingroom_repair.py @@ -0,0 +1,69 @@ +# ============================================================================= +# 企微IT智能服务台 — 会议室报修记录模型 +# ============================================================================= +# 说明:记录员工通过小鱼终端提交的会议室设备报修 +# 报修提交后自动创建IT工单会话(Conversation),关联conversation_id +# 同时通过企微消息通知IT管理员 +# ============================================================================= + +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class MeetingroomRepair(Base): + """会议室报修记录模型。 + + 员工在终端上发起报修后,记录报修信息并关联创建的IT工单会话。 + 报修状态跟随工单会话状态流转。 + + Attributes: + id: 自增主键 + terminal_sn: 终端序列号 + meetingroom_id: 企微会议室ID + meetingroom_name: 会议室名称(冗余) + device_type: 故障设备类型(projector/video_conf/aircon/desk_chair/network/other) + fault_description: 故障描述 + reporter_name: 报修人姓名(可能匿名) + reporter_userid: 报修人企微userid(可能为空) + conversation_id: 关联的IT工单会话ID + status: 报修状态(0=待处理 1=处理中 2=已解决 3=已关闭) + created_at: 创建时间 + updated_at: 更新时间 + """ + + __tablename__ = "meetingroom_repair" + + # 自增主键 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # 终端序列号 + terminal_sn: Mapped[str] = mapped_column(String(64), index=True, nullable=False, comment="终端序列号") + # 企微会议室ID + meetingroom_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False, comment="企微会议室ID") + # 会议室名称(冗余,便于报修列表展示) + meetingroom_name: Mapped[str] = mapped_column(String(100), nullable=False, default="", comment="会议室名称") + # 故障设备类型 + device_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="故障设备类型") + # 故障描述 + fault_description: Mapped[str] = mapped_column(Text, nullable=False, comment="故障描述") + # 报修人姓名(可能匿名) + reporter_name: Mapped[str] = mapped_column(String(100), nullable=False, default="匿名", comment="报修人姓名") + # 报修人企微userid(可能为空) + reporter_userid: Mapped[str] = mapped_column(String(64), nullable=False, default="", comment="报修人企微userid") + # 关联的IT工单会话ID + conversation_id: Mapped[str] = mapped_column(String(36), index=True, nullable=False, comment="关联IT工单会话ID") + # 报修状态 + status: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="报修状态: 0=待处理 1=处理中 2=已解决 3=已关闭") + # 创建时间 + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + # 更新时间 + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py new file mode 100644 index 0000000..9241912 --- /dev/null +++ b/backend/app/models/quiz.py @@ -0,0 +1,221 @@ +# ============================================================================= +# 企微IT智能服务台 — 答题与积分模型 +# ============================================================================= +# 说明:包含3张表,支撑排队等待期间的答题+积分系统: +# 1. quiz_questions: IT知识题库(7类×10题=70题起步) +# 2. quiz_answers: 答题记录(每次答题一条记录) +# 3. employee_points: 员工积分账户(跨会话累积,5级等级体系) +# +# 答题双模式: +# 模式A(info_locked=false)— 诊断题:与当前问题相关的选择题,答案附加到会话上下文 +# 模式B(info_locked=true) — IT知识题:纯教育性质,提升IT素养 +# +# 插队规则: +# queue_priority = min(quiz_answered_count // 3, 2) # 每答3题前移1位,上限2位 +# 积分规则: +# 答对 +10分,答错不扣分,跨会话累积 +# 0-99 IT小白 → 100-299 IT入门 → 300-599 IT达人 → 600-999 IT专家 → 1000+ IT大师 +# ============================================================================= + +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import Boolean, DateTime, Index, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class QuizQuestion(Base): + """IT知识题库 — 排队等待期间向员工推送的选择题。 + + 分为两类(通过 type 字段区分): + - knowledge: IT知识题(模式B,info_locked=true时推送) + - diagnostic: 诊断题(模式A,info_locked=false时推送,答案附加到会话上下文) + + 诊断题按 problem_category 组织,每类3-5题。 + 知识题按 category 组织,每类10题。 + + Attributes: + id: 题目唯一标识(UUID) + type: 题目类型(knowledge=IT知识题 / diagnostic=诊断题) + category: 题目类别(network/vpn/email/system/printer/security/office) + problem_category: 诊断题对应的问题类别(仅diagnostic类型有效,如"vpn_disconnect") + difficulty: 难度(easy/medium/hard) + question: 题目文本 + options: 选项数组(JSON,["选项A", "选项B", "选项C", "选项D"]) + correct_index: 正确答案索引(0-3) + explanation: 答案解析 + is_active: 是否启用 + created_at: 创建时间 + """ + + __tablename__ = "quiz_questions" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + type: Mapped[str] = mapped_column( + String(20), nullable=False, default="knowledge", + comment="题目类型: knowledge(IT知识题) / diagnostic(诊断题)" + ) + category: Mapped[str] = mapped_column( + String(50), nullable=False, + comment="题目类别: network/vpn/email/system/printer/security/office" + ) + problem_category: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, + comment="诊断题对应的问题类别(仅diagnostic类型有效)" + ) + difficulty: Mapped[str] = mapped_column( + String(20), nullable=False, default="medium", + comment="难度: easy/medium/hard" + ) + question: Mapped[str] = mapped_column( + Text, nullable=False, comment="题目文本" + ) + options: Mapped[list] = mapped_column( + JSON, nullable=False, comment="选项数组: ['选项A', '选项B', ...]" + ) + correct_index: Mapped[int] = mapped_column( + Integer, nullable=False, comment="正确答案索引(0-based)" + ) + explanation: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="答案解析" + ) + is_active: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, comment="是否启用" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=datetime.now, + comment="创建时间" + ) + + __table_args__ = ( + Index("idx_quiz_q_type", "type"), + Index("idx_quiz_q_category", "category"), + Index("idx_quiz_q_active", "is_active"), + ) + + def __repr__(self) -> str: + return f"" + + +class QuizAnswer(Base): + """答题记录 — 每次答题一条记录。 + + Attributes: + id: 记录ID + employee_id: 员工ID + conversation_id: 关联会话ID(可空,非排队时答题无会话) + question_id: 题目ID + selected_index: 员工选择的答案索引 + is_correct: 是否答对 + points_earned: 获得积分(答对=10,答错=0) + created_at: 答题时间 + """ + + __tablename__ = "quiz_answers" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + employee_id: Mapped[str] = mapped_column( + String(64), nullable=False, comment="员工ID" + ) + conversation_id: Mapped[Optional[str]] = mapped_column( + String(36), nullable=True, comment="关联会话ID" + ) + question_id: Mapped[str] = mapped_column( + String(36), nullable=False, comment="题目ID" + ) + selected_index: Mapped[int] = mapped_column( + Integer, nullable=False, comment="选择的答案索引" + ) + is_correct: Mapped[bool] = mapped_column( + Boolean, nullable=False, comment="是否答对" + ) + points_earned: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, comment="获得积分" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=datetime.now, + comment="答题时间" + ) + + __table_args__ = ( + Index("idx_quiz_a_employee", "employee_id"), + Index("idx_quiz_a_conversation", "conversation_id"), + Index("idx_quiz_a_created", "created_at"), + ) + + def __repr__(self) -> str: + return f"" + + +class EmployeePoints(Base): + """员工积分账户 — 跨会话累积,5级等级体系。 + + 积分规则:答对一题 +10分,答错不扣分。 + 等级体系: + 0-99 IT小白 (灰色) + 100-299 IT入门 (蓝色) + 300-599 IT达人 (绿色) + 600-999 IT专家 (琥珀) + 1000+ IT大师 (珊瑚红) + + Attributes: + employee_id: 员工ID(主键) + total_points: 累计积分 + answered_count: 答题总数 + correct_count: 答对总数 + level: 当前等级名称 + updated_at: 最后更新时间 + """ + + __tablename__ = "employee_points" + + employee_id: Mapped[str] = mapped_column( + String(64), primary_key=True, comment="员工ID" + ) + total_points: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, comment="累计积分" + ) + answered_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, comment="答题总数" + ) + correct_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, comment="答对总数" + ) + level: Mapped[str] = mapped_column( + String(20), nullable=False, default="IT小白", comment="当前等级" + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=datetime.now, + onupdate=datetime.now, comment="最后更新时间" + ) + + def __repr__(self) -> str: + return f"" + + @staticmethod + def calculate_level(points: int) -> str: + """根据积分计算等级名称。 + + Args: + points: 当前累计积分 + + Returns: + 等级名称字符串 + """ + if points >= 1000: + return "IT大师" + elif points >= 600: + return "IT专家" + elif points >= 300: + return "IT达人" + elif points >= 100: + return "IT入门" + else: + return "IT小白" diff --git a/backend/app/models/triage_session.py b/backend/app/models/triage_session.py new file mode 100644 index 0000000..7093a5d --- /dev/null +++ b/backend/app/models/triage_session.py @@ -0,0 +1,229 @@ +# ============================================================================= +# 企微IT智能服务台 — 分诊会话模型 +# ============================================================================= +# 说明:对应数据库 triage_sessions 表 +# 存储 AI 分诊的完整会话记录,包括分诊步骤、收集的上下文、路由结果等。 +# ============================================================================= + +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import DateTime, Float, Index, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class TriageSession(Base): + """分诊会话模型 — 对应 triage_sessions 表。 + + 存储员工发起的 AI 分诊全流程数据,从发起分诊到最终路由。 + + Attributes: + id: 分诊会话ID(UUID) + conversation_id: 关联的企微会话ID + user_id: 员工企微UserID + user_name: 员工姓名 + user_dept: 员工部门 + user_level: 员工IT技能等级 + device_info: 设备信息 + request_title: 问题标题 + request_content: 问题原文 + source: 来源渠道(wecom_h5 / api / other) + problem_type: AI识别的问题类型(硬件/软件/网络/安全/账号/其他) + problem_category: AI识别的问题分类 + confidence: AI置信度(0.0-1.0) + urgency: 紧急度(high/medium/low) + suggested_route: AI建议路由(ai_self/human/auto_approval) + matched_knowledge: 匹配到的知识条目 + match_score: 知识匹配分数 + context_tags: 上下文标签列表(JSON数组) + triage_steps: 分诊步骤数据(JSON数组) + collected_context: 已收集的上下文列表(JSON数组) + status: 分诊状态(pending/triaging/routed/skipped/timeout) + route_action: 最终路由动作(ai_self/human/auto_approval/skip) + route_note: 路由备注 + operator_id: 操作坐席ID + operated_at: 操作时间 + created_at: 创建时间 + updated_at: 更新时间 + """ + + __tablename__ = "triage_sessions" + + # 主键 + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: str(uuid.uuid4()), + ) + + # 会话关联 + conversation_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + comment="关联的企微会话ID", + ) + + # 用户信息 + user_id: Mapped[str] = mapped_column( + String(100), + nullable=False, + comment="员工企微UserID", + ) + user_name: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="员工姓名", + ) + user_dept: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="员工部门", + ) + user_level: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + comment="员工IT技能等级", + ) + device_info: Mapped[Optional[str]] = mapped_column( + String(200), + nullable=True, + comment="设备信息", + ) + + # 问题描述 + request_title: Mapped[str] = mapped_column( + String(200), + nullable=False, + comment="问题标题", + ) + request_content: Mapped[str] = mapped_column( + Text, + nullable=False, + comment="问题原文", + ) + source: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="wecom_h5", + comment="来源渠道", + ) + + # AI 分诊分析结果 + problem_type: Mapped[Optional[str]] = mapped_column( + String(50), + nullable=True, + comment="问题类型:硬件/软件/网络/安全/账号/其他", + ) + problem_category: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="问题分类", + ) + confidence: Mapped[Optional[float]] = mapped_column( + Float, + nullable=True, + comment="AI置信度(0.0-1.0)", + ) + urgency: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="medium", + comment="紧急度:high/medium/low", + ) + suggested_route: Mapped[Optional[str]] = mapped_column( + String(50), + nullable=True, + comment="AI建议路由:ai_self/human/auto_approval", + ) + matched_knowledge: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True, + comment="匹配到的知识条目", + ) + match_score: Mapped[Optional[float]] = mapped_column( + Float, + nullable=True, + comment="知识匹配分数", + ) + context_tags: Mapped[List[str]] = mapped_column( + JSON, + nullable=False, + default=list, + comment="上下文标签列表", + ) + + # 分诊步骤数据 + triage_steps: Mapped[List[Dict[str, Any]]] = mapped_column( + JSON, + nullable=False, + default=list, + comment="分诊步骤数据:[{question, options:[{label, probability}]}]", + ) + collected_context: Mapped[List[str]] = mapped_column( + JSON, + nullable=False, + default=list, + comment="已收集的上下文列表", + ) + + # 状态与路由 + status: Mapped[str] = mapped_column( + String(30), + nullable=False, + default="pending", + comment="分诊状态:pending/triaging/routed/skipped/timeout", + ) + route_action: Mapped[Optional[str]] = mapped_column( + String(50), + nullable=True, + comment="最终路由动作:ai_self/human/auto_approval/skip", + ) + route_note: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="路由备注", + ) + operator_id: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="操作坐席ID", + ) + operated_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="操作时间", + ) + + # 时间戳 + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=datetime.now, + comment="创建时间", + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=datetime.now, + onupdate=datetime.now, + comment="更新时间", + ) + + # 索引 + __table_args__ = ( + Index("idx_triage_status", "status"), + Index("idx_triage_urgency", "urgency"), + Index("idx_triage_conversation", "conversation_id"), + Index("idx_triage_created", "created_at"), + Index("idx_triage_user", "user_id"), + ) + + def __repr__(self) -> str: + """分诊会话对象的字符串表示。""" + return ( + f"" + ) diff --git a/backend/app/schemas/exclusion.py b/backend/app/schemas/exclusion.py new file mode 100644 index 0000000..c7d975e --- /dev/null +++ b/backend/app/schemas/exclusion.py @@ -0,0 +1,184 @@ +# ============================================================================= +# 企微IT智能服务台 — 代答排除 Pydantic Schema +# ============================================================================= +# 说明:代答排除模块的请求/响应数据模型,覆盖管理后台所有接口。 +# ============================================================================= + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +# ============================================================================= +# 请求 Schema +# ============================================================================= + +class ExclusionRuleCreate(BaseModel): + """新建排除规则请求。 + + Attributes: + rule_name: 规则名称(唯一) + rule_description: 规则描述 + priority: 优先级(P0/P1/P2/P3) + match_type: 匹配方式(keyword/regex/intent/category) + match_condition: 匹配条件 + match_scope: 匹配范围 + action_type: 命中后动作类型 + transfer_message: 转人工提示语 + """ + + rule_name: str = Field(..., min_length=1, max_length=200, description="规则名称") + rule_description: Optional[str] = Field(None, description="规则描述") + priority: str = Field("P2", description="优先级:P0/P1/P2/P3") + match_type: str = Field(..., description="匹配方式:keyword/regex/intent/category") + match_condition: str = Field(..., min_length=1, description="匹配条件") + match_scope: List[str] = Field(default_factory=lambda: ["ai_auto_reply"], description="匹配范围") + action_type: str = Field("transfer_human", description="命中后动作类型") + transfer_message: Optional[str] = Field(None, description="转人工提示语") + + +class ExclusionRuleUpdate(BaseModel): + """编辑排除规则请求。 + + 所有字段可选,仅更新传入的字段。 + + Attributes: + rule_name: 规则名称 + rule_description: 规则描述 + priority: 优先级 + match_type: 匹配方式 + match_condition: 匹配条件 + match_scope: 匹配范围 + action_type: 命中后动作类型 + transfer_message: 转人工提示语 + """ + + rule_name: Optional[str] = Field(None, min_length=1, max_length=200, description="规则名称") + rule_description: Optional[str] = Field(None, description="规则描述") + priority: Optional[str] = Field(None, description="优先级") + match_type: Optional[str] = Field(None, description="匹配方式") + match_condition: Optional[str] = Field(None, description="匹配条件") + match_scope: Optional[List[str]] = Field(None, description="匹配范围") + action_type: Optional[str] = Field(None, description="命中后动作类型") + transfer_message: Optional[str] = Field(None, description="转人工提示语") + + +class ExclusionRuleToggle(BaseModel): + """启用/停用规则请求。 + + Attributes: + status: 目标状态(enabled/disabled) + """ + + status: str = Field(..., description="目标状态:enabled/disabled") + + +class ExclusionTestRequest(BaseModel): + """测试匹配请求。 + + Attributes: + message: 测试消息文本 + rule_id: 指定规则ID(可选,不指定则测试所有启用规则) + """ + + message: str = Field(..., min_length=1, description="测试消息文本") + rule_id: Optional[str] = Field(None, description="指定规则ID") + + +# ============================================================================= +# 查询参数 Schema +# ============================================================================= + +class ExclusionRuleQuery(BaseModel): + """排除规则列表查询参数。 + + Attributes: + status: 状态筛选 + match_type: 匹配方式筛选 + priority: 优先级筛选 + keyword: 关键词搜索(规则名称/描述) + page: 页码 + page_size: 每页数量 + """ + + status: Optional[str] = Field(None, description="状态筛选") + match_type: Optional[str] = Field(None, description="匹配方式筛选") + priority: Optional[str] = Field(None, description="优先级筛选") + keyword: Optional[str] = Field(None, description="关键词搜索") + page: int = Field(1, ge=1, description="页码") + page_size: int = Field(20, ge=1, le=100, description="每页数量") + + +# ============================================================================= +# 响应 Schema +# ============================================================================= + +class ExclusionRuleResponse(BaseModel): + """排除规则响应。 + + Attributes: + id: 规则ID + rule_name: 规则名称 + rule_description: 规则描述 + priority: 优先级 + match_type: 匹配方式 + match_condition: 匹配条件 + match_scope: 匹配范围 + action_type: 命中后动作类型 + transfer_message: 转人工提示语 + status: 状态 + hit_count: 命中次数 + created_by: 创建人ID + created_at: 创建时间 + updated_at: 更新时间 + """ + + id: str = Field(..., description="规则ID") + rule_name: str = Field(..., description="规则名称") + rule_description: Optional[str] = Field(None, description="规则描述") + priority: str = Field(..., description="优先级") + match_type: str = Field(..., description="匹配方式") + match_condition: str = Field(..., description="匹配条件") + match_scope: List[str] = Field(default_factory=list, description="匹配范围") + action_type: str = Field(..., description="命中后动作类型") + transfer_message: Optional[str] = Field(None, description="转人工提示语") + status: str = Field(..., description="状态") + hit_count: int = Field(0, description="命中次数") + created_by: str = Field(..., description="创建人ID") + created_at: Optional[datetime] = Field(None, description="创建时间") + updated_at: Optional[datetime] = Field(None, description="更新时间") + + model_config = {"from_attributes": True} + + +class ExclusionTestResponse(BaseModel): + """测试匹配响应。 + + Attributes: + matched: 是否命中 + matched_detail: 命中详情 + rule_name: 命中的规则名称 + action_type: 命中后动作类型 + """ + + matched: bool = Field(False, description="是否命中") + matched_detail: Optional[str] = Field(None, description="命中详情") + rule_name: Optional[str] = Field(None, description="命中的规则名称") + action_type: Optional[str] = Field(None, description="命中后动作类型") + + +class ExclusionStatsResponse(BaseModel): + """排除规则统计概要响应。 + + Attributes: + enabled_count: 启用规则数 + disabled_count: 停用规则数 + monthly_hits: 本月命中次数 + monthly_transfers: 本月转人工次数 + """ + + enabled_count: int = Field(0, description="启用规则数") + disabled_count: int = Field(0, description="停用规则数") + monthly_hits: int = Field(0, description="本月命中次数") + monthly_transfers: int = Field(0, description="本月转人工次数") diff --git a/backend/app/schemas/meetingroom.py b/backend/app/schemas/meetingroom.py index 9babe57..f684e32 100644 --- a/backend/app/schemas/meetingroom.py +++ b/backend/app/schemas/meetingroom.py @@ -145,3 +145,48 @@ class TerminalBindingListResponse(BaseModel): list: List[TerminalBindingResponse] = Field(default_factory=list) total: int = 0 + + +# ============================================================================= +# 报修相关 +# ============================================================================= + +class RepairRequest(BaseModel): + """提交报修请求。""" + + terminal_sn: str = Field(..., min_length=1, max_length=64, description="终端序列号") + meetingroom_id: int = Field(..., description="企微会议室ID") + meetingroom_name: str = Field("", max_length=100, description="会议室名称") + device_type: str = Field(..., min_length=1, max_length=50, description="故障设备类型: projector/video_conf/aircon/desk_chair/network/other") + fault_description: str = Field(..., min_length=1, description="故障描述") + reporter_name: Optional[str] = Field(None, max_length=100, description="报修人姓名(为空则匿名)") + reporter_userid: Optional[str] = Field(None, max_length=64, description="报修人企微userid") + + +class RepairResponse(BaseModel): + """报修提交响应。""" + + repair_id: int = Field(..., description="报修记录ID") + conversation_id: str = Field(..., description="关联的IT工单会话ID") + status: int = Field(0, description="报修状态") + + +# ============================================================================= +# 操作指南相关 +# ============================================================================= + +class GuideItem(BaseModel): + """操作指南条目。""" + + id: int = Field(..., description="指南ID") + category: str = Field(..., description="设备类型") + title: str = Field(..., description="指南标题") + brief: str = Field("", description="简要操作步骤") + detail_url: str = Field("", description="详细文档URL") + icon: str = Field("📋", description="图标emoji") + + +class GuideListResponse(BaseModel): + """操作指南列表响应。""" + + guides: List[GuideItem] = Field(default_factory=list, description="指南列表") diff --git a/backend/app/schemas/triage.py b/backend/app/schemas/triage.py new file mode 100644 index 0000000..f711c0a --- /dev/null +++ b/backend/app/schemas/triage.py @@ -0,0 +1,324 @@ +# ============================================================================= +# 企微IT智能服务台 — 分诊交互 Pydantic Schema +# ============================================================================= +# 说明:分诊模块的请求/响应数据模型,覆盖 H5 端和坐席端所有接口。 +# ============================================================================= + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +# ============================================================================= +# 基础嵌套模型 +# ============================================================================= + +class TriageOption(BaseModel): + """分诊选项。 + + Attributes: + label: 选项标签文本 + probability: AI 推荐概率(0.0-1.0) + """ + + label: str = Field(..., description="选项标签文本") + probability: Optional[float] = Field(None, ge=0.0, le=1.0, description="AI推荐概率") + + +class TriageStep(BaseModel): + """分诊步骤。 + + Attributes: + question: 步骤问题文本 + options: 选项列表 + """ + + question: str = Field(..., description="步骤问题文本") + options: List[TriageOption] = Field(default_factory=list, description="选项列表") + + +# ============================================================================= +# H5 端请求 Schema +# ============================================================================= + +class TriageStartRequest(BaseModel): + """发起分诊请求。 + + Attributes: + conversation_id: 会话ID + question: 员工问题文本 + """ + + conversation_id: str = Field(..., description="会话ID") + question: str = Field(..., min_length=1, description="员工问题文本") + + +class TriageStepRequest(BaseModel): + """提交步骤选择请求。 + + Attributes: + triage_id: 分诊会话ID + step_index: 当前步骤序号(0-based) + selected_label: 选择的选项标签 + """ + + triage_id: str = Field(..., description="分诊会话ID") + step_index: int = Field(..., ge=0, description="当前步骤序号") + selected_label: str = Field(..., description="选择的选项标签") + + +class TriageSkipRequest(BaseModel): + """跳过步骤请求。 + + Attributes: + triage_id: 分诊会话ID + step_index: 要跳过的步骤序号 + """ + + triage_id: str = Field(..., description="分诊会话ID") + step_index: int = Field(..., ge=0, description="要跳过的步骤序号") + + +class TriageTransferRequest(BaseModel): + """转人工请求。 + + Attributes: + triage_id: 分诊会话ID + context: 已收集的上下文列表 + """ + + triage_id: str = Field(..., description="分诊会话ID") + context: List[str] = Field(default_factory=list, description="已收集的上下文列表") + + +class TriageCompleteRequest(BaseModel): + """分诊完成请求。 + + Attributes: + triage_id: 分诊会话ID + context: 已收集的上下文列表 + """ + + triage_id: str = Field(..., description="分诊会话ID") + context: List[str] = Field(default_factory=list, description="已收集的上下文列表") + + +# ============================================================================= +# H5 端响应 Schema +# ============================================================================= + +class TriageStartResponse(BaseModel): + """发起分诊响应。 + + Attributes: + triage_id: 分诊会话ID + steps: 分诊步骤列表 + total: 总步骤数 + confidence: AI 置信度 + urgency: 紧急度 + suggested_route: AI 建议路由 + """ + + triage_id: str = Field(..., description="分诊会话ID") + steps: List[TriageStep] = Field(default_factory=list, description="分诊步骤列表") + total: int = Field(0, description="总步骤数") + confidence: Optional[float] = Field(None, description="AI 置信度") + urgency: str = Field("medium", description="紧急度") + suggested_route: Optional[str] = Field(None, description="AI 建议路由") + + +class TriageStepResponse(BaseModel): + """提交步骤选择响应。 + + Attributes: + next_step: 下一步骤数据(无下一步时为 null) + collected_context: 已收集的上下文列表 + """ + + next_step: Optional[TriageStep] = Field(None, description="下一步骤数据") + collected_context: List[str] = Field(default_factory=list, description="已收集的上下文列表") + + +class TriageTransferResponse(BaseModel): + """转人工响应。 + + Attributes: + conversation_id: 会话ID + status: 会话状态 + """ + + conversation_id: str = Field(..., description="会话ID") + status: str = Field("waiting_agent", description="会话状态") + + +class TriageCompleteResponse(BaseModel): + """分诊完成响应。 + + Attributes: + reply: AI 生成的最终回复 + confidence: AI 置信度 + """ + + reply: str = Field(..., description="AI 生成的最终回复") + confidence: float = Field(0.0, description="AI 置信度") + + +# ============================================================================= +# 坐席端请求 Schema +# ============================================================================= + +class TriageRouteRequest(BaseModel): + """坐席路由操作请求。 + + Attributes: + route_action: 路由动作(ai_self/human/auto_approval/skip) + route_note: 路由备注 + """ + + route_action: str = Field(..., description="路由动作:ai_self/human/auto_approval/skip") + route_note: Optional[str] = Field(None, description="路由备注") + + +class TriageExcludeOptionsRequest(BaseModel): + """坐席排除/推荐分诊选项请求。 + + Attributes: + excluded_labels: 要排除的选项标签列表 + recommended_label: 推荐的选项标签 + """ + + excluded_labels: List[str] = Field(default_factory=list, description="要排除的选项标签列表") + recommended_label: Optional[str] = Field(None, description="推荐的选项标签") + + +# ============================================================================= +# 坐席端响应 Schema +# ============================================================================= + +class TriageSessionResponse(BaseModel): + """分诊会话列表项响应。 + + Attributes: + id: 分诊会话ID + conversation_id: 会话ID + user_id: 员工ID + user_name: 员工姓名 + user_dept: 员工部门 + request_title: 问题标题 + problem_type: 问题类型 + problem_category: 问题分类 + confidence: AI 置信度 + urgency: 紧急度 + suggested_route: AI 建议路由 + status: 分诊状态 + route_action: 路由动作 + route_note: 路由备注 + operator_id: 操作坐席ID + created_at: 创建时间 + operated_at: 操作时间 + """ + + id: str = Field(..., description="分诊会话ID") + conversation_id: str = Field(..., description="会话ID") + user_id: str = Field(..., description="员工ID") + user_name: Optional[str] = Field(None, description="员工姓名") + user_dept: Optional[str] = Field(None, description="员工部门") + request_title: str = Field(..., description="问题标题") + problem_type: Optional[str] = Field(None, description="问题类型") + problem_category: Optional[str] = Field(None, description="问题分类") + confidence: Optional[float] = Field(None, description="AI 置信度") + urgency: str = Field("medium", description="紧急度") + suggested_route: Optional[str] = Field(None, description="AI 建议路由") + status: str = Field("pending", description="分诊状态") + route_action: Optional[str] = Field(None, description="路由动作") + route_note: Optional[str] = Field(None, description="路由备注") + operator_id: Optional[str] = Field(None, description="操作坐席ID") + created_at: Optional[datetime] = Field(None, description="创建时间") + operated_at: Optional[datetime] = Field(None, description="操作时间") + + model_config = {"from_attributes": True} + + +class TriageDetailResponse(BaseModel): + """分诊详情响应(含完整数据)。 + + Attributes: + id: 分诊会话ID + conversation_id: 会话ID + user_id: 员工ID + user_name: 员工姓名 + user_dept: 员工部门 + user_level: 员工IT技能等级 + device_info: 设备信息 + request_title: 问题标题 + request_content: 问题原文 + source: 来源渠道 + problem_type: 问题类型 + problem_category: 问题分类 + confidence: AI 置信度 + urgency: 紧急度 + suggested_route: AI 建议路由 + matched_knowledge: 匹配到的知识条目 + match_score: 知识匹配分数 + context_tags: 上下文标签列表 + triage_steps: 分诊步骤数据 + collected_context: 已收集的上下文列表 + status: 分诊状态 + route_action: 路由动作 + route_note: 路由备注 + operator_id: 操作坐席ID + created_at: 创建时间 + updated_at: 更新时间 + operated_at: 操作时间 + """ + + id: str = Field(..., description="分诊会话ID") + conversation_id: str = Field(..., description="会话ID") + user_id: str = Field(..., description="员工ID") + user_name: Optional[str] = Field(None, description="员工姓名") + user_dept: Optional[str] = Field(None, description="员工部门") + user_level: Optional[str] = Field(None, description="员工IT技能等级") + device_info: Optional[str] = Field(None, description="设备信息") + request_title: str = Field(..., description="问题标题") + request_content: str = Field(..., description="问题原文") + source: str = Field("wecom_h5", description="来源渠道") + problem_type: Optional[str] = Field(None, description="问题类型") + problem_category: Optional[str] = Field(None, description="问题分类") + confidence: Optional[float] = Field(None, description="AI 置信度") + urgency: str = Field("medium", description="紧急度") + suggested_route: Optional[str] = Field(None, description="AI 建议路由") + matched_knowledge: Optional[str] = Field(None, description="匹配到的知识条目") + match_score: Optional[float] = Field(None, description="知识匹配分数") + context_tags: List[str] = Field(default_factory=list, description="上下文标签列表") + triage_steps: List[Dict[str, Any]] = Field(default_factory=list, description="分诊步骤数据") + collected_context: List[str] = Field(default_factory=list, description="已收集的上下文列表") + status: str = Field("pending", description="分诊状态") + route_action: Optional[str] = Field(None, description="路由动作") + route_note: Optional[str] = Field(None, description="路由备注") + operator_id: Optional[str] = Field(None, description="操作坐席ID") + created_at: Optional[datetime] = Field(None, description="创建时间") + updated_at: Optional[datetime] = Field(None, description="更新时间") + operated_at: Optional[datetime] = Field(None, description="操作时间") + + model_config = {"from_attributes": True} + + +class TriageStatsResponse(BaseModel): + """分诊看板统计概要响应。 + + Attributes: + pending_total: 待分诊总数 + today_triaged: 今日已分诊数 + ai_self_count: AI 自答数 + human_count: 转人工数 + auto_approval_count: 自动审批数 + avg_duration_sec: 平均耗时(秒) + """ + + pending_total: int = Field(0, description="待分诊总数") + today_triaged: int = Field(0, description="今日已分诊数") + ai_self_count: int = Field(0, description="AI 自答数") + human_count: int = Field(0, description="转人工数") + auto_approval_count: int = Field(0, description="自动审批数") + avg_duration_sec: float = Field(0.0, description="平均耗时(秒)") diff --git a/backend/app/services/ai_handler.py b/backend/app/services/ai_handler.py index b3e079e..5fa10de 100644 --- a/backend/app/services/ai_handler.py +++ b/backend/app/services/ai_handler.py @@ -23,6 +23,15 @@ from app.services.ai_service import AIService logger = logging.getLogger(__name__) +# -------------------------------------------------------------------------- +# 代答排除命中后动作类型 +# -------------------------------------------------------------------------- +_EXCLUSION_ACTION_TRANSFER_HUMAN = "transfer_human" +_EXCLUSION_ACTION_TRANSFER_WITH_CONTEXT = "transfer_human_with_context" +_EXCLUSION_ACTION_PROMPT_TRANSFER = "prompt_transfer" +_EXCLUSION_ACTION_SILENT_TRANSFER = "silent_transfer" + + # -------------------------------------------------------------------------- # 打招呼关键词(匹配后 AI 引导用户描述问题,不计数) # -------------------------------------------------------------------------- @@ -114,10 +123,15 @@ class AIReplyResult: - "ai_hit": AI 命中知识库 - "ai_miss": AI 未命中,需转人工 - "ai_fallback": AI 调用异常,降级模板回复 + - "excluded": 代答排除命中(转人工/提示转人工/静默转人工) is_guidance: 是否为引导类消息(打招呼或呼叫人工),前端据此决定 UI 展示 should_count: 是否应增加 ai_substantive_reply_count(仅 AI 命中时为 True) should_transfer: 是否应转人工(状态改为 queued) dify_conversation_id: Dify 会话ID(用于多轮对话上下文,AI 命中/未命中时更新) + excluded_action: 代答排除命中动作类型(仅 reply_type="excluded" 时有值) + action: 结构化操作卡片数据(审批/入口推荐),仅 Dify JSON 输出且 action 非空时有值 + options: 结构化选项按钮列表,仅 Dify JSON 输出且 options 非空时有值 + is_structured: 是否为 JSON 结构化回复(True=后端需推送 dynamic_recommend WS) """ content: str reply_type: str @@ -125,6 +139,11 @@ class AIReplyResult: should_count: bool = False should_transfer: bool = False dify_conversation_id: Optional[str] = None + excluded_action: Optional[str] = None + # v2.0 新增(2026-07-13):结构化消息字段 + action: Optional[dict] = None + options: Optional[list] = None + is_structured: bool = False class AIHandler: @@ -189,16 +208,20 @@ class AIHandler: content: str, dify_conversation_id: Optional[str] = None, user_id: Optional[str] = None, + conversation_id: Optional[str] = None, + db=None, ) -> AIReplyResult: """处理用户消息,返回统一的 AI 回复结果。 - 按照优先级依次检测:打招呼 → 呼叫人工 → AI 调用。 + 按照优先级依次检测:打招呼 → 呼叫人工 → 代答排除 → AI 调用。 每种路径返回不同的 reply_type,由调用方根据结果更新会话状态和计数。 Args: content: 用户消息内容 dify_conversation_id: Dify 会话ID(用于多轮对话上下文) user_id: 用户标识(用于 Dify 日志追溯) + conversation_id: 企微会话ID(用于代答排除检查,传入则启用排除检查) + db: 数据库会话(用于代答排除检查,传入则启用排除检查) Returns: AIReplyResult: 统一的 AI 回复结果 @@ -232,7 +255,31 @@ class AIHandler: ) # ================================================================== - # 3. 调用 Dify API 获取 AI 回复 + # 3. 代答排除检查(AI 回复前) + # 仅在传入 conversation_id 和 db 时启用 + # ================================================================== + if conversation_id and db and content: + try: + from app.services.exclusion_service import get_exclusion_service + exclusion_service = get_exclusion_service() + exclusion_result = await exclusion_service.check_exclusions( + db=db, + message=content, + conversation_id=conversation_id, + user_id=user_id or "", + ) + + if exclusion_result.matched: + # 命中排除规则,执行对应动作 + return self._handle_exclusion_hit( + exclusion_result, dify_conversation_id, + ) + except Exception as e: + # 排除检查异常不阻断主流程,继续 AI 回复 + logger.error(f"代答排除检查异常(降级继续AI回复): {e}") + + # ================================================================== + # 4. 调用 Dify API 获取 AI 回复 # ================================================================== try: ai_result = await self.ai_service.get_reply( @@ -272,7 +319,7 @@ class AIHandler: except Exception as e: # ============================================================== - # 4. AI 调用异常:降级模板回复 + # 5. AI 调用异常:降级模板回复 # - 不计数(修复原 h5.py 降级误计数的 Bug) # - 不转人工(降级是临时故障,用户可继续尝试) # ============================================================== @@ -287,3 +334,94 @@ class AIHandler: should_transfer=False, dify_conversation_id=dify_conversation_id, ) + + def _handle_exclusion_hit( + self, + exclusion_result, + dify_conversation_id: Optional[str], + ) -> AIReplyResult: + """处理代答排除命中,根据 action_type 执行对应动作。 + + 4 种命中后动作(决策 #9): + 1. transfer_human: 转人工坐席,员工看到"已转接人工"提示 + 2. transfer_human_with_context: 同上 + 附带 collected_context + 3. prompt_transfer: 返回 transfer_message,等用户确认 + 4. silent_transfer: 静默转人工,员工无感知 + + Args: + exclusion_result: ExclusionCheckResult 命中结果 + dify_conversation_id: Dify 会话ID + + Returns: + AIReplyResult: 统一的 AI 回复结果 + """ + action = exclusion_result.action_type + rule_name = exclusion_result.rule_name + detail = exclusion_result.matched_detail + transfer_msg = exclusion_result.transfer_message or "已为您转接人工坐席,请稍候..." + + logger.info( + f"代答排除命中: rule={rule_name}, action={action}, detail={detail}" + ) + + if action == _EXCLUSION_ACTION_TRANSFER_HUMAN: + # 转人工坐席 + return AIReplyResult( + content=transfer_msg, + reply_type="excluded", + is_guidance=False, + should_count=False, + should_transfer=True, + dify_conversation_id=dify_conversation_id, + excluded_action=action, + ) + + elif action == _EXCLUSION_ACTION_TRANSFER_WITH_CONTEXT: + # 携带上下文转人工(与 transfer_human 相同的回复,上下文由调用方处理) + return AIReplyResult( + content=transfer_msg, + reply_type="excluded", + is_guidance=False, + should_count=False, + should_transfer=True, + dify_conversation_id=dify_conversation_id, + excluded_action=action, + ) + + elif action == _EXCLUSION_ACTION_PROMPT_TRANSFER: + # 仅提示转人工,等用户确认(不自动转) + prompt_msg = transfer_msg or "此问题建议联系人工坐席处理,是否转接?" + return AIReplyResult( + content=prompt_msg, + reply_type="excluded", + is_guidance=False, + should_count=False, + should_transfer=False, + dify_conversation_id=dify_conversation_id, + excluded_action=action, + ) + + elif action == _EXCLUSION_ACTION_SILENT_TRANSFER: + # 静默转人工(不提示用户) + return AIReplyResult( + content="", + reply_type="excluded", + is_guidance=False, + should_count=False, + should_transfer=True, + dify_conversation_id=dify_conversation_id, + excluded_action=action, + ) + + else: + # 未知动作,默认转人工 + logger.warning(f"未知排除动作类型: {action},默认转人工") + return AIReplyResult( + content=transfer_msg, + reply_type="excluded", + is_guidance=False, + should_count=False, + should_transfer=True, + dify_conversation_id=dify_conversation_id, + excluded_action=action, + ) diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 736dcc8..ab6569d 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -12,6 +12,7 @@ import json import logging import asyncio +import time from typing import Any, Dict, List, Optional, AsyncGenerator import httpx @@ -24,13 +25,21 @@ logger = logging.getLogger(__name__) class AIService: """AI 服务:封装 Dify API,提供 AI 回复能力。 - 支持两种调用模式: - 1. 非流式(简单场景):一次性获取完整回复 - 2. 流式(推荐):SSE 流式返回,前端可逐字显示 + 支持三种调用模式: + 1. 非流式(简单场景):一次性获取完整回复(经 dify2openai 代理) + 2. 流式(推荐):SSE 流式返回,前端可逐字显示(经 dify2openai 代理) + 3. ★ 原生直连(v2.1 新增):绕过代理,直连 Dify /v1/chat-messages + + v2.1 改造原因: + - dify2openai 代理存在 [object Object] 序列化 bug + - 直连 Dify 原生 API 响应格式更简单(answer 字段直接返回内容) + - 结构化回复(get_structured_reply)优先使用原生 API 参考:现有系统交接文档 - - API URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions - - Key: http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat + - 代理 URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions + - 原生 URL: http://yw-dify.dc.servyou-it.com/v1/chat-messages + - Key: app-7jkRkAzvX4QM9v9SM3P8mMEO(审批意图副本,推荐使用) + - ⚠️ 已弃用老Key app-UaTWYdBSwN6VktKQlbh5YN5H(老线上应用,禁止使用) """ def __init__(self): @@ -39,18 +48,24 @@ class AIService: 做什么:从配置读取 Dify API 地址和认证信息 为什么:集中管理 API 配置,便于切换测试/生产环境 """ - # Dify 兼容 OpenAI 格式的 API 端点 + # Dify 兼容 OpenAI 格式的 API 端点(代理) self.api_url = settings.dify_api_url # Dify API Key(格式:base_url|app_id|app_name) self.api_key = settings.dify_api_key # 请求超时(秒) self.timeout = settings.dify_timeout - # httpx 异步客户端(复用连接池) + # ★ v2.1: Dify 原生 API 配置(绕过代理) + self.native_base_url = settings.dify_native_base_url + self.native_api_key = settings.dify_native_api_key + + # httpx 异步客户端(复用连接池)— 代理用 self._client: Optional[httpx.AsyncClient] = None + # ★ v2.1: 原生 API 专用客户端(不同 auth header) + self._native_client: Optional[httpx.AsyncClient] = None async def _get_client(self) -> httpx.AsyncClient: - """获取或创建 httpx 异步客户端。 + """获取或创建 httpx 异步客户端(代理用)。 做什么:懒加载 httpx.AsyncClient,复用连接池 为什么:避免每次请求都创建新连接,提升性能 @@ -65,16 +80,37 @@ class AIService: ) return self._client + async def _get_native_client(self) -> httpx.AsyncClient: + """获取或创建 Dify 原生 API 专用客户端(v2.1 新增)。 + + 做什么:懒加载 httpx.AsyncClient,使用 Dify 原生 auth 格式 + 为什么:原生 API 使用 `Bearer app-xxx` 认证(非管道分隔格式), + 需要独立客户端避免 auth header 冲突 + """ + if self._native_client is None or self._native_client.is_closed: + self._native_client = httpx.AsyncClient( + timeout=httpx.Timeout(self.timeout), + headers={ + "Authorization": f"Bearer {self.native_api_key}", + "Content-Type": "application/json", + } + ) + return self._native_client + async def close(self): """关闭 httpx 客户端。 - 做什么:释放连接池资源 + 做什么:释放连接池资源(代理 + 原生) 为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中 """ if self._client and not self._client.is_closed: await self._client.aclose() self._client = None logger.debug("AIService httpx client closed") + if self._native_client and not self._native_client.is_closed: + await self._native_client.aclose() + self._native_client = None + logger.debug("AIService native client closed") # -------------------------------------------------------------------------- # 非流式调用:一次性获取 AI 完整回复 @@ -290,6 +326,392 @@ class AIService: "hit": False, } + # -------------------------------------------------------------------------- + # 结构化调用:blocking 模式,返回解析后的 JSON {text, action, options} + # -------------------------------------------------------------------------- + async def _call_dify_native( + self, + message: str, + conversation_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + """直连 Dify 原生 API(v2.1 新增,绕过 dify2openai 代理)。 + + 做什么: + 1. POST {base_url}/v1/chat-messages(blocking 模式) + 2. 解析返回的 answer 字段(直接包含 AI 回复内容) + 3. 返回 raw_content 供上层 JSON 解析 + + 为什么: + - dify2openai 代理存在 [object Object] 序列化 bug + - 原生 API 响应格式更简单:{"answer": "...", "conversation_id": "..."} + - 不经过 OpenAI 兼容层转换,避免格式损失 + + Args: + message: 员工发送的消息内容 + conversation_id: Dify 会话ID(用于多轮对话上下文) + user_id: 员工企微 UserID + + Returns: + Dict 或 None: + - 成功:{"raw_content": str, "conversation_id": str, "response_time_ms": float} + - 失败:None(调用方应 fallback 到代理路径) + """ + if not self.native_base_url or not self.native_api_key: + return None # 未配置原生 API,调用方走代理路径 + + url = f"{self.native_base_url}/v1/chat-messages" + payload = { + "inputs": {}, + "query": message, + "response_mode": "blocking", # 阻塞模式,等待完整回复 + "user": user_id or "unknown", + } + # 传入 Dify 会话ID,保持多轮对话上下文 + if conversation_id: + payload["conversation_id"] = conversation_id + + try: + client = await self._get_native_client() + start_time = time.perf_counter() + logger.info(f"调用 Dify 原生 API: message={message[:50]}...") + response = await client.post(url, json=payload) + response.raise_for_status() + response_time_ms = (time.perf_counter() - start_time) * 1000 + data = response.json() + + # 原生 API 返回格式:{"answer": "...", "conversation_id": "..."} + raw_content = data.get("answer", "") + dify_conv_id = data.get("conversation_id", conversation_id or "") + + if not raw_content: + logger.warning("Dify 原生 API 返回空 answer") + return None + + logger.info( + f"Dify 原生 API 返回: content_len={len(raw_content)}, " + f"response_time={response_time_ms:.0f}ms, " + f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}" + ) + + return { + "raw_content": raw_content, + "conversation_id": dify_conv_id, + "response_time_ms": response_time_ms, + } + + except httpx.TimeoutException: + logger.warning("Dify 原生 API 超时,将回退到代理路径") + return None + except httpx.HTTPStatusError as e: + logger.warning(f"Dify 原生 API HTTP 错误: status={e.response.status_code},将回退到代理路径") + return None + except Exception as e: + logger.warning(f"Dify 原生 API 调用失败: {e},将回退到代理路径") + return None + + async def get_structured_reply( + self, + message: str, + conversation_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Dict[str, Any]: + """调用 Dify API 获取结构化 AI 回复(blocking 模式,JSON 输出)。 + + 改造后的 Dify 主对话应用输出 JSON 格式: + {"text": "...", "action": {...}|null, "options": [...]|null} + + 本方法负责: + 1. 以 blocking 模式调用 Dify(stream=False) + 2. 尝试解析返回内容为 JSON + 3. 解析失败时降级为纯文本(向后兼容旧 Prompt) + 4. 返回统一结构 + + Args: + message: 员工发送的消息内容(可能包含图片描述前缀) + conversation_id: Dify 会话ID(用于多轮对话上下文) + user_id: 员工企微 UserID + + Returns: + Dict: { + "text": str, # 回复文字(始终有值) + "action": dict|None, # 操作卡片数据(审批/入口推荐) + "options": list|None, # 选项按钮列表 + "hit": bool, # 是否命中知识库 + "conversation_id": str, # Dify 会话ID + "raw_content": str, # 原始返回内容(调试用) + "is_structured": bool, # 是否成功解析为 JSON + } + """ + # ★ v2.1: 优先尝试 Dify 原生 API(绕过 dify2openai 代理) + # 为什么:代理存在 [object Object] 序列化 bug,原生 API 直接返回 answer 字段 + # 降级:原生 API 未配置或调用失败时,自动回退到下方代理路径 + native_result = await self._call_dify_native(message, conversation_id, user_id) + if native_result is not None: + raw_content = native_result["raw_content"] + dify_conv_id = native_result["conversation_id"] + response_time_ms = native_result["response_time_ms"] + + # 尝试 JSON 解析(复用同一解析逻辑) + parsed = self._parse_structured_response(raw_content) + + if parsed: + # JSON 解析成功 + text = parsed.get("text", "") + action = parsed.get("action") + options = parsed.get("options") + diagnosis_stage = parsed.get("diagnosis_stage") + hit = self._check_knowledge_hit(text) if text else False + + logger.info( + f"Dify 原生 structured 返回: hit={hit}, " + f"text_len={len(text)}, " + f"has_action={action is not None}, " + f"has_options={options is not None}, " + f"diagnosis_stage={diagnosis_stage}, " + f"response_time={response_time_ms:.0f}ms" + ) + + if response_time_ms > 10000: + logger.warning(f"Dify 原生慢响应告警: {response_time_ms:.0f}ms") + + return { + "text": text, + "action": action, + "options": options, + "hit": hit, + "conversation_id": dify_conv_id, + "raw_content": raw_content, + "is_structured": True, + "diagnosis_stage": diagnosis_stage, + "response_time_ms": round(response_time_ms, 1), + } + else: + # JSON 解析失败,降级为纯文本 + logger.warning( + "Dify 原生返回非 JSON 格式,降级为纯文本。" + f"content={raw_content[:100]}..." + ) + hit = self._check_knowledge_hit(raw_content) if raw_content else False + + return { + "text": raw_content, + "action": None, + "options": None, + "hit": hit, + "conversation_id": dify_conv_id, + "raw_content": raw_content, + "is_structured": False, + "diagnosis_stage": None, + "response_time_ms": round(response_time_ms, 1), + } + + # === 代理路径(fallback)=== + # 原生 API 不可用或调用失败时,走 dify2openai 代理(原有逻辑) + logger.info("使用 dify2openai 代理路径(原生 API 不可用或失败)") + payload = { + "model": "Chat", + "messages": [{"role": "user", "content": message}], + "stream": False, # blocking 模式 + "temperature": 0.3, # 略高于流式,给 JSON 结构化留一点灵活性 + } + if conversation_id: + payload["conversation_id"] = conversation_id + if user_id: + payload["user"] = user_id + + try: + client = await self._get_client() + # Phase 6B: 记录 Dify 调用开始时间(性能监控) + start_time = time.perf_counter() + logger.info(f"调用 Dify API (structured): message={message[:50]}...") + response = await client.post(self.api_url, json=payload) + response.raise_for_status() + # Phase 6B: 计算响应耗时 + response_time_ms = (time.perf_counter() - start_time) * 1000 + data = response.json() + + # 解析 OpenAI 兼容格式返回 + choices = data.get("choices", []) + if not choices: + logger.warning("Dify API 返回空 choices (structured)") + return { + "text": "", + "action": None, + "options": None, + "hit": False, + "conversation_id": conversation_id or "", + "raw_content": "", + "is_structured": False, + "diagnosis_stage": None, + "response_time_ms": round(response_time_ms, 1), + } + + raw_content = choices[0]["message"]["content"] + dify_conv_id = data.get("conversation_id", conversation_id or "") + + # 尝试解析 JSON + parsed = self._parse_structured_response(raw_content) + + if parsed: + # JSON 解析成功 + text = parsed.get("text", "") + action = parsed.get("action") + options = parsed.get("options") + # Phase 6A: 提取诊断阶段(diagnosis_stage) + diagnosis_stage = parsed.get("diagnosis_stage") + hit = self._check_knowledge_hit(text) if text else False + + logger.info( + f"Dify structured 返回: hit={hit}, " + f"text_len={len(text)}, " + f"has_action={action is not None}, " + f"has_options={options is not None}, " + f"diagnosis_stage={diagnosis_stage}, " + f"response_time={response_time_ms:.0f}ms, " + f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}" + ) + + # Phase 6B: 慢响应告警(>10秒) + if response_time_ms > 10000: + logger.warning( + f"Dify 慢响应告警: {response_time_ms:.0f}ms " + f"(conv={dify_conv_id[:20] if dify_conv_id else 'new'})" + ) + + return { + "text": text, + "action": action, + "options": options, + "hit": hit, + "conversation_id": dify_conv_id, + "raw_content": raw_content, + "is_structured": True, + "diagnosis_stage": diagnosis_stage, + "response_time_ms": round(response_time_ms, 1), + } + else: + # JSON 解析失败,降级为纯文本(向后兼容旧 Prompt) + logger.warning( + "Dify 返回非 JSON 格式,降级为纯文本。" + f"content={raw_content[:100]}..." + ) + hit = self._check_knowledge_hit(raw_content) if raw_content else False + + return { + "text": raw_content, + "action": None, + "options": None, + "hit": hit, + "conversation_id": dify_conv_id, + "raw_content": raw_content, + "is_structured": False, + "diagnosis_stage": None, + "response_time_ms": round(response_time_ms, 1), + } + + except httpx.TimeoutException: + elapsed = (time.perf_counter() - start_time) * 1000 + logger.error(f"Dify API 超时 (structured): {elapsed:.0f}ms") + return { + "text": "AI 服务响应超时,请稍后再试或转人工坐席。", + "action": None, + "options": None, + "hit": False, + "conversation_id": conversation_id or "", + "raw_content": "", + "is_structured": False, + "diagnosis_stage": None, + "response_time_ms": round(elapsed, 1), + } + except httpx.HTTPStatusError as e: + elapsed = (time.perf_counter() - start_time) * 1000 + logger.error(f"Dify API HTTP 错误 (structured): status={e.response.status_code}, {elapsed:.0f}ms") + return { + "text": "AI 服务暂时不可用,请转人工坐席。", + "action": None, + "options": None, + "hit": False, + "conversation_id": conversation_id or "", + "raw_content": "", + "is_structured": False, + "diagnosis_stage": None, + "response_time_ms": round(elapsed, 1), + } + except Exception as e: + elapsed = (time.perf_counter() - start_time) * 1000 + logger.error(f"Dify API 调用失败 (structured): {e}, {elapsed:.0f}ms") + return { + "text": "AI 服务异常,请转人工坐席或稍后重试。", + "action": None, + "options": None, + "hit": False, + "conversation_id": conversation_id or "", + "raw_content": "", + "is_structured": False, + "diagnosis_stage": None, + "response_time_ms": round(elapsed, 1), + } + + def _parse_structured_response(self, content: str) -> Optional[Dict[str, Any]]: + """尝试将 Dify 返回内容解析为结构化 JSON。 + + 支持以下格式: + 1. 纯 JSON: {"text": "...", "action": null, "options": null} + 2. 带 markdown 代码块: ```json\n{...}\n``` + 3. 前后有多余文本的 JSON(提取第一个 { 到最后一个 }) + + Args: + content: Dify 返回的原始内容 + + Returns: + 解析后的 dict,或 None(解析失败) + """ + if not content or not content.strip(): + return None + + text = content.strip() + + # 尝试 1: 直接解析 + try: + result = json.loads(text) + if isinstance(result, dict) and "text" in result: + return result + except json.JSONDecodeError: + pass + + # 尝试 2: 去除 markdown 代码块 + if text.startswith("```"): + # 去除 ```json 或 ``` 开头和结尾的 ``` + lines = text.split("\n") + # 去掉第一行(```json 或 ```) + if lines[0].strip().startswith("```"): + lines = lines[1:] + # 去掉最后一行(```) + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines).strip() + try: + result = json.loads(text) + if isinstance(result, dict) and "text" in result: + return result + except json.JSONDecodeError: + pass + + # 尝试 3: 提取第一个 { 到最后一个 } + first_brace = content.find("{") + last_brace = content.rfind("}") + if first_brace != -1 and last_brace != -1 and last_brace > first_brace: + json_str = content[first_brace:last_brace + 1] + try: + result = json.loads(json_str) + if isinstance(result, dict) and "text" in result: + return result + except json.JSONDecodeError: + pass + + return None + # -------------------------------------------------------------------------- # 判断是否命中知识库 # -------------------------------------------------------------------------- diff --git a/backend/app/services/closing_service.py b/backend/app/services/closing_service.py new file mode 100644 index 0000000..4a3b483 --- /dev/null +++ b/backend/app/services/closing_service.py @@ -0,0 +1,922 @@ +# ============================================================================= +# 企微IT智能服务台 — 关闭机制服务 +# ============================================================================= +# 说明:管理会话的完整关闭生命周期,包括五种关闭场景: +# 1. AI自助解决(employee_self_resolve)— 员工确认AI已解决 +# 2. 坐席结单确认(agent_initiate_resolve → employee_confirm_resolve)— 坐席发起→员工确认 +# 3. 员工主动关闭(employee_initiative_close)— 员工自行关闭 +# 4. 超时自动关闭(auto_timeout_close)— 系统定时任务触发 +# 5. 不满意重新接入(reopen_conversation)— 24h内重开创建新会话 +# +# 状态流转: +# ai_handling →(AI解决+员工确认)→ resolved +# ai_handling →(30min超时→提醒→10min)→ resolved +# serving →(坐席结单+员工确认)→ resolved +# serving →(坐席结单+员工拒绝)→ serving(继续服务) +# serving →(10min无响应)→ pending_close →(5min)→ resolved +# resolved →(24h内重开)→ 新会话(关联原会话ID) +# +# 知识沉淀:resolved后检查是否有诊断报告+修复记录→生成知识条目草稿→管理后台审核 +# ============================================================================= + +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, Optional +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.agent import Agent +from app.models.conversation import Conversation +from app.models.message import Message +from app.services.ws_manager import manager as ws_manager +from app.utils.response import AppException + +logger = logging.getLogger(__name__) + +# ============================================================================= +# 超时配置(分钟) +# ============================================================================= +# AI处理阶段超时:30分钟无互动 → 发送提醒 → 再过10分钟 → 自动关闭 +AI_HANDLING_TIMEOUT_MINUTES = 30 +AI_HANDLING_REMINDER_TO_CLOSE_MINUTES = 10 + +# 坐席服务阶段:已在 reminder_task.py 中定义 +# REMINDER_TIMEOUT_MINUTES = 3 → 发送提醒 +# CLOSE_TIMEOUT_MINUTES = 10 → 标记 pending_close +# 本服务新增:pending_close → resolved 的超时 +PENDING_CLOSE_AUTO_RESOLVE_MINUTES = 5 + +# 重开时限(小时) +REOPEN_WINDOW_HOURS = 24 + + +class ClosingService: + """关闭机制服务 — 管理会话的完整关闭生命周期。 + + 核心职责: + - 处理五种关闭场景的状态转换 + - 推送 WS 事件通知前端 + - 触发知识沉淀流程 + - 处理24h内重开 + + 设计决策: + - 坐席结单需员工确认(G1),员工有权否决 + - AI解决支持卡片确认和关键词识别两种方式(G2) + - 超时自动关闭作为兜底,防止会话挂起 + - 知识沉淀为异步流程,不阻塞关闭主流程 + """ + + def __init__(self, db: AsyncSession): + """初始化关闭机制服务。 + + Args: + db: 数据库异步会话 + """ + self.db = db + + # ========================================================================== + # 场景1:AI自助解决 — 员工确认已解决 + # ========================================================================== + + async def employee_self_resolve( + self, + employee_id: str, + resolve_summary: Optional[str] = None, + ) -> Conversation: + """员工确认AI已解决问题(AI自助场景)。 + + 触发场景: + - 对话流中的"已解决"确认卡片按钮 + - 员工发送包含关闭关键词的消息 + + 状态转换:ai_handling → resolved + 关闭方:employee + 关闭方式:ai_self + + Args: + employee_id: 员工企微UserID + resolve_summary: 员工可选填写的解决摘要 + + Returns: + Conversation: 更新后的会话对象 + + Raises: + AppException: 会话不存在或状态不允许 + """ + conversation = await self._get_active_conversation(employee_id) + + # 状态校验:只有 ai_handling 状态可以走AI自助解决 + if conversation.status not in ("ai_handling", "queued"): + raise AppException( + 1004, + f"当前会话状态为 {conversation.status},无法通过AI自助关闭。" + "如需关闭请联系坐席。", + ) + + # 更新会话状态 + conversation.status = "resolved" + conversation.resolved_by = "employee" + conversation.resolved_method = "ai_self" + conversation.resolve_summary = resolve_summary or "员工确认AI已解决" + conversation.updated_at = datetime.now() + self.db.add(conversation) + await self.db.flush() + + logger.info( + f"AI自助解决关闭: conv_id={conversation.id}, employee={employee_id}" + ) + + # 推送 WS 事件:会话已关闭 + await self._push_conversation_resolved(conversation, "employee", "ai_self") + + # 触发知识沉淀(异步,不阻塞) + await self._trigger_knowledge_sedimentation(conversation) + + return conversation + + # ========================================================================== + # 场景2:坐席结单 → 员工确认 + # ========================================================================== + + async def agent_initiate_resolve( + self, + conversation_id: str, + agent_id: str, + resolve_summary: str, + ) -> Conversation: + """坐席发起结单,触发员工确认流程。 + + 状态转换:serving → pending_close + 后续:员工确认 → resolved / 员工拒绝 → serving / 超时 → resolved + + WS事件:推送 resolve_confirm 给员工,前端弹出确认卡片 + + Args: + conversation_id: 会话ID + agent_id: 坐席ID(必须是主责坐席) + resolve_summary: 结单摘要(问题类型+根因+解决方式) + + Returns: + Conversation: 更新后的会话对象 + + Raises: + AppException: 会话不存在、状态不允许、非主责坐席 + """ + conversation = await self._get_conversation_by_id(conversation_id) + + # 状态校验 + if conversation.status == "resolved": + raise AppException(3002, "会话已结单") + if conversation.status != "serving": + raise AppException( + 1004, + f"当前会话状态为 {conversation.status},只有服务中的会话可以结单。", + ) + + # 权限校验:只有主责坐席才能结单 + if conversation.assigned_agent_id != agent_id: + raise AppException(3027, "只有主责坐席才能结单") + + # 更新会话状态为待关闭 + conversation.status = "pending_close" + conversation.resolve_summary = resolve_summary + conversation.pending_close_at = datetime.now() + conversation.updated_at = datetime.now() + self.db.add(conversation) + await self.db.flush() + + logger.info( + f"坐席发起结单: conv_id={conversation_id}, agent={agent_id}, " + f"summary={resolve_summary[:50]}..." + ) + + # 推送 WS 事件:结单确认请求 → 员工端弹出确认卡片 + await self._push_resolve_confirm(conversation, agent_id, resolve_summary) + + return conversation + + async def employee_confirm_resolve( + self, + employee_id: str, + ) -> Conversation: + """员工确认坐席的结单请求。 + + 状态转换:pending_close → resolved + 关闭方:agent + 关闭方式:agent_confirm + + Args: + employee_id: 员工企微UserID + + Returns: + Conversation: 更新后的会话对象 + + Raises: + AppException: 无 pending_close 状态的会话 + """ + # 查找该员工处于 pending_close 状态的会话 + stmt = select(Conversation).where( + Conversation.employee_id == employee_id, + Conversation.status == "pending_close", + ).order_by(Conversation.updated_at.desc()) + result = await self.db.execute(stmt) + conversation = result.scalars().first() + + if not conversation: + raise AppException(1005, "没有待确认的结单请求") + + # 更新会话状态 + conversation.status = "resolved" + conversation.resolved_by = "agent" + conversation.resolved_method = "agent_confirm" + conversation.updated_at = datetime.now() + self.db.add(conversation) + + # 更新坐席服务数 -1 + await self._decrement_agent_load(conversation.assigned_agent_id) + + await self.db.flush() + + logger.info( + f"员工确认结单: conv_id={conversation.id}, employee={employee_id}" + ) + + # 推送 WS 事件 + await self._push_conversation_resolved(conversation, "agent", "agent_confirm") + + # 触发知识沉淀 + await self._trigger_knowledge_sedimentation(conversation) + + return conversation + + async def employee_reject_resolve( + self, + employee_id: str, + reason: Optional[str] = None, + ) -> Conversation: + """员工拒绝坐席的结单请求,会话回到服务中。 + + 状态转换:pending_close → serving + 重置超时提醒相关字段,让坐席继续服务 + + Args: + employee_id: 员工企微UserID + reason: 拒绝原因(可选) + + Returns: + Conversation: 更新后的会话对象 + """ + stmt = select(Conversation).where( + Conversation.employee_id == employee_id, + Conversation.status == "pending_close", + ).order_by(Conversation.updated_at.desc()) + result = await self.db.execute(stmt) + conversation = result.scalars().first() + + if not conversation: + raise AppException(1005, "没有待确认的结单请求") + + # 恢复会话状态 + conversation.status = "serving" + conversation.pending_close_at = None + conversation.reminder_sent = False + conversation.reminder_sent_at = None + conversation.updated_at = datetime.now() + self.db.add(conversation) + await self.db.flush() + + logger.info( + f"员工拒绝结单,恢复服务: conv_id={conversation.id}, " + f"employee={employee_id}, reason={reason or '未提供'}" + ) + + # 推送 WS 事件给坐席:员工拒绝了结单 + if conversation.assigned_agent_id: + await ws_manager.send_to_agent( + conversation.assigned_agent_id, + { + "type": "resolve_rejected", + "data": { + "conversation_id": str(conversation.id), + "employee_id": employee_id, + "reason": reason or "员工未提供原因", + "timestamp": datetime.now().isoformat(), + }, + }, + ) + + # 推送给员工:已恢复服务 + await ws_manager.send_to_employee( + employee_id, + { + "type": "resolve_rejected", + "data": { + "conversation_id": str(conversation.id), + "message": "已为您恢复服务,坐席将继续处理您的问题。", + "timestamp": datetime.now().isoformat(), + }, + }, + ) + + return conversation + + # ========================================================================== + # 场景3:员工主动关闭 + # ========================================================================== + + async def employee_initiative_close( + self, + employee_id: str, + close_reason: Optional[str] = None, + ) -> Conversation: + """员工主动关闭会话(非AI解决场景)。 + + 状态转换:ai_handling/queued/serving → resolved + 关闭方:employee + 关闭方式:employee_initiative + + 适用场景: + - 员工问题自行解决,不需要AI或坐席帮助 + - 员工不想继续等待 + - 员工问题已通过其他渠道解决 + + Args: + employee_id: 员工企微UserID + close_reason: 关闭原因(可选) + + Returns: + Conversation: 更新后的会话对象 + """ + conversation = await self._get_active_conversation(employee_id) + + # 更新会话状态 + conversation.status = "resolved" + conversation.resolved_by = "employee" + conversation.resolved_method = "employee_initiative" + conversation.resolve_summary = close_reason or "员工主动关闭" + conversation.updated_at = datetime.now() + self.db.add(conversation) + + # 如果有分配坐席,更新坐席服务数 + if conversation.assigned_agent_id: + await self._decrement_agent_load(conversation.assigned_agent_id) + + await self.db.flush() + + logger.info( + f"员工主动关闭: conv_id={conversation.id}, employee={employee_id}, " + f"reason={close_reason or '未提供'}" + ) + + # 推送 WS 事件 + await self._push_conversation_resolved(conversation, "employee", "employee_initiative") + + return conversation + + # ========================================================================== + # 场景4:超时自动关闭(由 reminder_task.py 调用) + # ========================================================================== + + async def auto_timeout_close( + self, + conversation_id: str, + timeout_type: str = "pending_close", + ) -> Conversation: + """超时自动关闭会话。 + + 两种超时场景: + 1. pending_close 超时(坐席发起结单后5分钟员工未响应) + 2. ai_handling 超时(AI处理阶段30+10分钟无互动) + + 状态转换:pending_close/ai_handling → resolved + 关闭方:system_timeout + 关闭方式:auto_timeout + + Args: + conversation_id: 会话ID + timeout_type: 超时类型(pending_close / ai_handling) + + Returns: + Conversation: 更新后的会话对象 + """ + conversation = await self._get_conversation_by_id(conversation_id) + + # 更新会话状态 + conversation.status = "resolved" + conversation.resolved_by = "system_timeout" + conversation.resolved_method = "auto_timeout" + conversation.resolve_summary = f"系统超时自动关闭({timeout_type})" + conversation.updated_at = datetime.now() + self.db.add(conversation) + + # 如果有分配坐席,更新坐席服务数 + if conversation.assigned_agent_id: + await self._decrement_agent_load(conversation.assigned_agent_id) + + await self.db.flush() + + logger.info( + f"超时自动关闭: conv_id={conversation_id}, type={timeout_type}" + ) + + # 推送 WS 事件 + await self._push_conversation_resolved(conversation, "system_timeout", "auto_timeout") + + # 触发知识沉淀 + await self._trigger_knowledge_sedimentation(conversation) + + return conversation + + # ========================================================================== + # 场景5:24h内重开 + # ========================================================================== + + async def reopen_conversation( + self, + employee_id: str, + original_conversation_id: str, + ) -> Conversation: + """24小时内重开已关闭的会话。 + + 创建新会话并关联原会话ID,用于上下文继承。 + 新会话状态为 ai_handling,复用原会话的员工信息。 + + Args: + employee_id: 员工企微UserID + original_conversation_id: 原会话ID + + Returns: + Conversation: 新创建的会话对象 + + Raises: + AppException: 原会话不存在、未关闭、超过24h窗口 + """ + # 查找原会话 + original = await self._get_conversation_by_id(original_conversation_id) + + # 校验:原会话必须已关闭 + if original.status != "resolved": + raise AppException(1006, "只有已关闭的会话可以重开") + + # 校验:24小时窗口 + # 使用 updated_at 作为关闭时间近似(resolved后没有专门的 resolved_at 字段) + close_time = original.updated_at + if close_time: + elapsed = datetime.now() - close_time + if elapsed > timedelta(hours=REOPEN_WINDOW_HOURS): + raise AppException( + 1007, + f"已超过 {REOPEN_WINDOW_HOURS} 小时重开窗口,请发起新会话。", + ) + + # 创建新会话,关联原会话 + new_conversation = Conversation( + corp_id=original.corp_id, + employee_id=original.employee_id, + employee_name=original.employee_name, + department=original.department, + position=original.position, + level=original.level, + status="ai_handling", + is_vip=original.is_vip, + urgency_score=max(original.urgency_score, 2), # 重开提升紧急度 + info_locked=original.info_locked, # 继承信息锁定状态 + queue_priority=0, + reference_conversation_id=str(original.id), # 关联原会话 + tags={"reopened": True, "original_conv_id": str(original.id)}, + last_message_summary="问题复发,重新接入", + ) + self.db.add(new_conversation) + await self.db.flush() + + logger.info( + f"重开会话: new_conv={new_conversation.id}, " + f"original={original_conversation_id}, employee={employee_id}" + ) + + # 推送 WS 事件给坐席端:有新会话进入 + await ws_manager.broadcast({ + "type": "conversation_created", + "data": { + "conversation_id": str(new_conversation.id), + "employee_id": employee_id, + "employee_name": new_conversation.employee_name, + "is_reopen": True, + "reference_conversation_id": str(original.id), + "urgency_score": new_conversation.urgency_score, + }, + }) + + return new_conversation + + # ========================================================================== + # 关键词识别(决策 G2) + # ========================================================================== + + @staticmethod + def check_resolve_keywords(message_content: str) -> bool: + """检查消息内容是否包含关闭关键词。 + + 用于 AI 对话中识别员工表达"已解决"意图。 + 当 AI 检测到关键词时,推送确认卡片让员工二次确认。 + + Args: + message_content: 员工发送的消息内容 + + Returns: + bool: 是否包含关闭关键词 + """ + # 延迟导入避免循环依赖 + from app.services.triage_service import RESOLVE_KEYWORDS + + content_lower = message_content.lower().strip() + for keyword in RESOLVE_KEYWORDS: + if keyword in content_lower: + return True + return False + + # ========================================================================== + # Phase 6A: 诊断闭环协调 — 基于 diagnosis_stage 判断 + # ========================================================================== + + @staticmethod + def get_diagnosis_stage(conversation: Conversation) -> Optional[str]: + """从会话 tags 中获取当前诊断阶段。 + + 做什么:读取 conversation.tags["diagnosis_stage"] 字段, + 该字段由 _persist_and_push_structured() 在每次 AI 回复时更新。 + 为什么:closing_service 需要知道 AI 的诊断进度, + 以决定是否建议关闭会话或触发结单流程。 + + Args: + conversation: 会话对象 + + Returns: + Optional[str]: 诊断阶段值(initial/gathering_info/diagnosing/ + recommending/resolved/escalating),无则 None + """ + if not conversation.tags: + return None + return conversation.tags.get("diagnosis_stage") + + @staticmethod + def should_suggest_resolve(conversation: Conversation) -> bool: + """判断是否应建议员工确认解决(基于 diagnosis_stage)。 + + 做什么:当 AI 返回 diagnosis_stage == "resolved" 时, + 表示 AI 认为问题已解决,系统可推送确认卡片。 + 为什么:相比纯关键词匹配,diagnosis_stage 是 AI 主动判断的结果, + 更准确地反映问题解决状态。 + + Args: + conversation: 会话对象 + + Returns: + bool: True 表示应推送解决确认卡片 + """ + stage = ClosingService.get_diagnosis_stage(conversation) + return stage == "resolved" + + @staticmethod + def should_escalate_to_human(conversation: Conversation) -> bool: + """判断是否应建议转人工(基于 diagnosis_stage)。 + + 做什么:当 AI 返回 diagnosis_stage == "escalating" 时, + 表示 AI 无法解决问题,应转人工坐席。 + 为什么:AI 主动判断无法解决比超时兜底更及时, + 能更快地将员工转给人工坐席。 + + Args: + conversation: 会话对象 + + Returns: + bool: True 表示应转人工 + """ + stage = ClosingService.get_diagnosis_stage(conversation) + return stage == "escalating" + + async def _notify_queue_position_update(self) -> None: + """通知所有排队员工其队列位置已更新(WS事件 queue_position_update)。 + + 当有会话被关闭/分配/取消时,排在后面的员工位置前移。 + 此方法查询所有排队中的会话,计算每个员工的新位置并推送。 + + 为了避免大量推送,仅在有人排队时执行。 + """ + from app.services.queue_service import get_queue_service + + try: + queue_service = get_queue_service() # 无参单例 + + # 查询所有排队中的会话 + stmt = select(Conversation).where( + Conversation.status == "queued" + ).order_by(Conversation.created_at.asc()) + result = await self.db.execute(stmt) + queued_conversations = result.scalars().all() + + if not queued_conversations: + return + + # 为每个排队员工计算新位置并推送 + for conv in queued_conversations: + try: + status = await queue_service.get_comprehensive_status(self.db, conv) + queue_info = status.get("queue", {}) + await ws_manager.send_to_employee( + conv.employee_id, + { + "type": "queue_position_update", + "data": { + "conversation_id": str(conv.id), + "position": queue_info.get("position", 0), + "segment": queue_info.get("segment", ""), + "ahead_count": queue_info.get("ahead_count", 0), + "queue_priority": conv.queue_priority, + "timestamp": datetime.now().isoformat(), + }, + } + ) + except Exception as e: + logger.debug(f"推送队列位置更新失败(单个): conv_id={conv.id}, {e}") + continue + + except Exception as e: + logger.warning(f"队列位置更新推送异常: {e}") + + # ========================================================================== + # 超时检查辅助方法(供 reminder_task.py 调用) + # ========================================================================== + + async def get_pending_close_timeout_sessions(self) -> list[Conversation]: + """获取 pending_close 超时需要自动关闭的会话列表。 + + 条件:status=pending_close 且 pending_close_at 超过5分钟 + """ + threshold = datetime.now() - timedelta(minutes=PENDING_CLOSE_AUTO_RESOLVE_MINUTES) + stmt = select(Conversation).where( + Conversation.status == "pending_close", + Conversation.pending_close_at.isnot(None), + Conversation.pending_close_at < threshold, + ) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def get_ai_handling_timeout_sessions(self) -> list[Conversation]: + """获取 ai_handling 超时需要自动关闭的会话列表。 + + 条件:status=ai_handling 且 last_message_at 超过 (30+10)=40 分钟 + """ + total_timeout = AI_HANDLING_TIMEOUT_MINUTES + AI_HANDLING_REMINDER_TO_CLOSE_MINUTES + threshold = datetime.now() - timedelta(minutes=total_timeout) + stmt = select(Conversation).where( + Conversation.status == "ai_handling", + Conversation.last_message_at.isnot(None), + Conversation.last_message_at < threshold, + ) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def get_ai_handling_reminder_sessions(self) -> list[Conversation]: + """获取 ai_handling 需要发送超时提醒的会话列表。 + + 条件:status=ai_handling 且 last_message_at 超过30分钟 且未发过提醒 + """ + threshold = datetime.now() - timedelta(minutes=AI_HANDLING_TIMEOUT_MINUTES) + stmt = select(Conversation).where( + Conversation.status == "ai_handling", + Conversation.last_message_at.isnot(None), + Conversation.last_message_at < threshold, + Conversation.reminder_sent == False, + ) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + # ========================================================================== + # 内部辅助方法 + # ========================================================================== + + async def _get_active_conversation(self, employee_id: str) -> Conversation: + """获取员工的活跃会话(非 resolved 状态)。 + + Args: + employee_id: 员工企微UserID + + Returns: + Conversation: 活跃会话对象 + + Raises: + AppException: 无活跃会话 + """ + stmt = select(Conversation).where( + Conversation.employee_id == employee_id, + Conversation.status.in_(["ai_handling", "queued", "serving", "pending_close"]), + ).order_by(Conversation.created_at.desc()) + result = await self.db.execute(stmt) + conversation = result.scalars().first() + + if not conversation: + raise AppException(1001, "当前没有活跃会话") + + return conversation + + async def _get_conversation_by_id(self, conversation_id: str) -> Conversation: + """根据ID获取会话。 + + Args: + conversation_id: 会话ID + + Returns: + Conversation: 会话对象 + + Raises: + AppException: 会话不存在 + """ + stmt = select(Conversation).where(Conversation.id == conversation_id) + result = await self.db.execute(stmt) + conversation = result.scalars().first() + + if not conversation: + raise AppException(3001, "会话不存在") + + return conversation + + async def _decrement_agent_load(self, agent_id: Optional[str]) -> None: + """减少坐席当前服务数。 + + Args: + agent_id: 坐席ID + """ + if not agent_id: + return + + stmt = select(Agent).where(Agent.user_id == agent_id) + result = await self.db.execute(stmt) + agent = result.scalars().first() + + if agent and agent.current_load > 0: + agent.current_load -= 1 + self.db.add(agent) + + async def _push_resolve_confirm( + self, + conversation: Conversation, + agent_id: str, + resolve_summary: str, + ) -> None: + """推送结单确认请求给员工(WS事件 resolve_confirm)。 + + 前端收到此事件后,在对话流中弹出确认卡片: + - 坐席摘要展示 + - "已解决"按钮 → 调用 employee_confirm_resolve + - "未解决"按钮 → 调用 employee_reject_resolve + - 提示:5分钟内不响应将自动关闭 + """ + payload = { + "type": "resolve_confirm", + "data": { + "conversation_id": str(conversation.id), + "agent_id": agent_id, + "resolve_summary": resolve_summary, + "auto_close_minutes": PENDING_CLOSE_AUTO_RESOLVE_MINUTES, + "timestamp": datetime.now().isoformat(), + }, + } + + try: + await ws_manager.send_to_employee(conversation.employee_id, payload) + except Exception as e: + logger.warning(f"推送 resolve_confirm 失败: {e}") + + async def _push_conversation_resolved( + self, + conversation: Conversation, + resolved_by: str, + resolved_method: str, + ) -> None: + """推送会话已关闭事件(WS事件 conversation_resolved)。 + + 通知坐席端和员工端会话已关闭。 + 同时触发: + 1. 队列位置更新通知(queue_position_update)— 通知所有排队员工位置变化 + 2. 自动分配下一个排队会话(三段排序) + """ + payload = { + "type": "conversation_resolved", + "data": { + "conversation_id": str(conversation.id), + "status": "resolved", + "resolved_by": resolved_by, + "resolved_method": resolved_method, + "resolve_summary": conversation.resolve_summary or "", + "timestamp": datetime.now().isoformat(), + }, + } + + # 推送给坐席端(广播,因为可能多个坐席需要看到状态变更) + try: + await ws_manager.broadcast(payload) + except Exception as e: + logger.warning(f"推送 conversation_resolved 给坐席失败: {e}") + + # 推送给员工端 + try: + await ws_manager.send_to_employee(conversation.employee_id, payload) + except Exception as e: + logger.warning(f"推送 conversation_resolved 给员工失败: {e}") + + # ------------------------------------------------------------------ + # 触发1:通知所有排队员工队列位置已更新(queue_position_update) + # ------------------------------------------------------------------ + # 会话关闭后,排在后面的员工位置前移1位 + try: + await self._notify_queue_position_update() + except Exception as e: + logger.warning(f"推送 queue_position_update 失败: {e}") + + # ------------------------------------------------------------------ + # 触发2:自动分配队列中的下一个会话(三段排序) + # ------------------------------------------------------------------ + # 坐席空闲后,从队列中按 VIP → 已梳理 → 待梳理 顺序分配 + try: + from app.services.session_service import SessionService + session_service = SessionService(self.db) + assigned = await session_service.auto_assign_from_queue() + if assigned: + logger.info(f"关闭后自动分配下一个会话: conv_id={assigned.id}") + except Exception as e: + logger.warning(f"关闭后自动分配失败(不阻塞): {e}") + + async def _push_auto_close_warning( + self, + conversation: Conversation, + minutes_remaining: int, + ) -> None: + """推送超时关闭警告(WS事件 auto_close_warning)。 + + 在 pending_close 后4分钟(1分钟前剩)时推送,提醒员工即将自动关闭。 + """ + payload = { + "type": "auto_close_warning", + "data": { + "conversation_id": str(conversation.id), + "minutes_remaining": minutes_remaining, + "message": f"您的会话将在 {minutes_remaining} 分钟后自动关闭," + f"如需继续服务请点击「未解决」。", + "timestamp": datetime.now().isoformat(), + }, + } + + try: + await ws_manager.send_to_employee(conversation.employee_id, payload) + except Exception as e: + logger.warning(f"推送 auto_close_warning 失败: {e}") + + async def _trigger_knowledge_sedimentation( + self, + conversation: Conversation, + ) -> None: + """触发知识沉淀流程(异步,不阻塞关闭主流程)。 + + 决策 G5:resolved后判断是否有诊断报告+修复记录 + → 生成知识条目草稿 → 管理后台审核入库 + + 当前实现:仅记录日志,后续接入诊断服务后完善。 + 知识沉淀为 P2 功能,此处预留接口。 + """ + # TODO: P2 阶段接入诊断服务后完善 + # 1. 检查是否有关联的诊断报告(DiagnosticReport) + # 2. 检查是否有修复记录(DiagnosticDispatch.fix_dispatched) + # 3. 如果有,调用 Dify 总结会话+诊断报告 → 生成知识条目草稿 + # 4. 草稿存入知识库待审核表 + logger.info( + f"知识沉淀触发(P2预留): conv_id={conversation.id}, " + f"method={conversation.resolved_method}, " + f"summary={conversation.resolve_summary[:50] if conversation.resolve_summary else 'N/A'}" + ) + + +# ============================================================================= +# 模块级单例工厂 +# ============================================================================= +# 与 queue_service / quiz_service 一致的模式: +# 每次调用时传入 db session,服务本身无状态 + +_closing_service_instance: Optional[ClosingService] = None + + +def get_closing_service(db: AsyncSession) -> ClosingService: + """获取关闭机制服务实例。 + + Args: + db: 数据库异步会话 + + Returns: + ClosingService: 关闭机制服务实例 + """ + global _closing_service_instance + if _closing_service_instance is None or _closing_service_instance.db is not db: + _closing_service_instance = ClosingService(db) + return _closing_service_instance diff --git a/backend/app/services/dify_triage_service.py b/backend/app/services/dify_triage_service.py new file mode 100644 index 0000000..5db609f --- /dev/null +++ b/backend/app/services/dify_triage_service.py @@ -0,0 +1,284 @@ +# ============================================================================= +# 企微IT智能服务台 — Dify 分诊服务 +# ============================================================================= +# 说明:对接 Dify OpenAI 兼容接口,调用独立分诊应用进行问题分析。 +# 功能: +# 1. analyze — 首次分诊分析,将问题拆分为分步选择题 +# 2. get_next_step — 根据已选选项动态调整后续步骤 +# 3. generate_reply — 根据收集的上下文生成最终回复 +# 降级处理:Dify 不可用时返回友好错误,不中断主流程。 +# ============================================================================= + +import json +import logging +from typing import Any, Dict, List, Optional + +import httpx + +from app.config import settings + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Dify 分诊 System Prompt +# ============================================================================= +TRIAGE_SYSTEM_PROMPT = """你是IT服务台智能分诊引擎,负责分析员工IT问题并拆分为分步选择题。 + +## 任务目标 +1. 识别问题类型(硬件/软件/网络/安全/账号/其他)和具体分类 +2. 评估置信度(0.0-1.0)和紧急度(high/medium/low) +3. 将复杂问题拆分为分步选择题(简单问题1-2步,复杂问题3-5步) +4. 每步最多4个选项,每个选项分配概率(0-1),所有选项概率之和为1 +5. 推荐路由渠道(ai_self/human/auto_approval) + +## 紧急度规则 +- 消息含"紧急/马上/宕机/无法工作/崩溃/死机/蓝屏"→high +- 消息含"报错/失败/连不上/打不开/不能用"→medium +- 其余→low + +## 排除选项 +excluded_options 中的选项不出现在后续步骤中。 + +## 输出约束 +必须输出合法JSON,不要输出解释性文字。JSON格式如下: +{ + "triage_type": "confirm|transfer|approval", + "confidence": 0.85, + "urgency": "high|medium|low", + "problem_type": "硬件|软件|网络|安全|账号|其他", + "problem_category": "Outlook", + "suggested_route": "ai_self|human|auto_approval", + "matched_knowledge": "匹配到的知识条目描述", + "match_score": 0.89, + "context_tags": ["标签1", "标签2"], + "triage_steps": [ + { + "question": "步骤问题文本", + "options": [ + {"label": "选项A", "probability": 0.68}, + {"label": "选项B", "probability": 0.22} + ] + } + ], + "total_steps": 3, + "reply": "AI回复文本(当triage_type=confirm时,引导员工选择)" +} + +## 置信度评估 +基于知识库匹配度、问题清晰度、上下文完整度综合评估。""" + + +class DifyTriageService: + """Dify 分诊应用对接服务。 + + 通过 OpenAI 兼容接口调用 Dify 独立分诊应用, + 支持首次分析、动态步骤调整和最终回复生成。 + + Attributes: + api_url: Dify OpenAI 兼容接口地址 + api_key: Dify API Key + timeout: 请求超时时间(秒) + """ + + def __init__(self): + """初始化 Dify 分诊服务。""" + self.api_url = settings.dify_triage_api_url + self.api_key = settings.dify_triage_api_key + self.timeout = settings.dify_triage_timeout + + def is_available(self) -> bool: + """检查 Dify 分诊服务是否可用。 + + Returns: + bool: API URL 和 Key 均已配置时返回 True + """ + return bool(self.api_url and self.api_key) + + async def analyze( + self, + question: str, + context: Optional[List[str]] = None, + excluded_options: Optional[List[str]] = None, + step_index: int = 0, + ) -> Dict[str, Any]: + """调用 Dify 分诊应用进行首次分析。 + + Args: + question: 员工问题文本 + context: 已收集的上下文标签(分步选择中累积) + excluded_options: 坐席已排除的选项标签 + step_index: 当前步骤序号(0=首次分诊) + + Returns: + Dict[str, Any]: Dify 返回的分诊结果 JSON + + Raises: + RuntimeError: Dify 不可用或返回格式错误 + """ + if not self.is_available(): + logger.warning("Dify 分诊服务未配置,降级处理") + raise RuntimeError("Dify 分诊服务未配置") + + # 构建用户消息内容(JSON 格式传入输入参数) + user_content = json.dumps( + { + "question": question, + "collected_context": context or [], + "excluded_options": excluded_options or [], + "step_index": step_index, + }, + ensure_ascii=False, + ) + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post( + f"{self.api_url}/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "triage-engine", + "messages": [ + { + "role": "system", + "content": TRIAGE_SYSTEM_PROMPT, + }, + {"role": "user", "content": user_content}, + ], + "temperature": 0.3, + "max_tokens": 2000, + }, + ) + resp.raise_for_status() + + # 解析 OpenAI 兼容响应格式 + resp_data = resp.json() + content = resp_data["choices"][0]["message"]["content"] + + # Dify 返回的是 JSON 字符串,需要解析 + # 兼容 markdown 代码块包裹的 JSON + content = content.strip() + if content.startswith("```json"): + content = content[7:] + if content.startswith("```"): + content = content[3:] + if content.endswith("```"): + content = content[:-3] + content = content.strip() + + result = json.loads(content) + logger.info( + "Dify 分诊分析成功: problem_type=%s, confidence=%s, urgency=%s", + result.get("problem_type"), + result.get("confidence"), + result.get("urgency"), + ) + return result + + except httpx.TimeoutException: + logger.error("Dify 分诊请求超时(%s秒)", self.timeout) + raise RuntimeError(f"Dify 分诊请求超时({self.timeout}秒)") + except httpx.HTTPStatusError as e: + logger.error("Dify 分诊 HTTP 错误: %s, status=%s", e, e.response.status_code) + raise RuntimeError(f"Dify 分诊服务返回错误: {e.response.status_code}") + except json.JSONDecodeError as e: + logger.error("Dify 分诊返回 JSON 解析失败: %s", e) + raise RuntimeError("Dify 分诊返回格式错误") + except Exception as e: + logger.error("Dify 分诊调用异常: %s", e, exc_info=True) + raise RuntimeError(f"Dify 分诊调用异常: {e}") + + async def generate_reply( + self, + question: str, + collected_context: List[str], + ) -> Dict[str, Any]: + """根据收集的上下文生成最终回复。 + + Args: + question: 原始问题文本 + collected_context: 分诊过程中收集的所有上下文 + + Returns: + Dict[str, Any]: 包含 reply 和 confidence 的字典 + + Raises: + RuntimeError: Dify 不可用或返回格式错误 + """ + if not self.is_available(): + logger.warning("Dify 分诊服务未配置,降级处理(生成回复)") + raise RuntimeError("Dify 分诊服务未配置") + + user_content = json.dumps( + { + "question": question, + "collected_context": collected_context, + "excluded_options": [], + "step_index": -1, # -1 表示最终回复生成 + }, + ensure_ascii=False, + ) + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.post( + f"{self.api_url}/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "triage-engine", + "messages": [ + { + "role": "system", + "content": TRIAGE_SYSTEM_PROMPT, + }, + {"role": "user", "content": user_content}, + ], + "temperature": 0.3, + "max_tokens": 2000, + }, + ) + resp.raise_for_status() + + resp_data = resp.json() + content = resp_data["choices"][0]["message"]["content"] + + content = content.strip() + if content.startswith("```json"): + content = content[7:] + if content.startswith("```"): + content = content[3:] + if content.endswith("```"): + content = content[:-3] + content = content.strip() + + result = json.loads(content) + return { + "reply": result.get("reply", ""), + "confidence": result.get("confidence", 0.0), + } + + except Exception as e: + logger.error("Dify 分诊生成回复异常: %s", e, exc_info=True) + raise RuntimeError(f"Dify 分诊生成回复异常: {e}") + + +# 单例 +_dify_triage_service: Optional[DifyTriageService] = None + + +def get_dify_triage_service() -> DifyTriageService: + """获取 DifyTriageService 单例。 + + Returns: + DifyTriageService: 单例实例 + """ + global _dify_triage_service + if _dify_triage_service is None: + _dify_triage_service = DifyTriageService() + return _dify_triage_service diff --git a/backend/app/services/exclusion_service.py b/backend/app/services/exclusion_service.py new file mode 100644 index 0000000..2d0d28a --- /dev/null +++ b/backend/app/services/exclusion_service.py @@ -0,0 +1,344 @@ +# ============================================================================= +# 企微IT智能服务台 — 代答排除匹配引擎 +# ============================================================================= +# 说明:责任链调度入口,按优先级排序规则,依次调用对应 Matcher, +# 命中即停止并记录日志、更新 hit_count。 +# +# 核心方法: +# 1. check_exclusions — 检查消息是否命中排除规则 +# 2. test_match — 测试匹配(管理后台用,不记录日志) +# 3. execute_action — 执行命中后动作(4种) +# ============================================================================= + +import logging +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import func, select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.exclusion_log import ExclusionLog +from app.models.exclusion_rule import ExclusionRule +from app.services.matchers import MATCHER_REGISTRY, MatchResult + +logger = logging.getLogger(__name__) + +# 优先级排序权重(P0 最高) +_PRIORITY_ORDER = {"P0": 0, "P1": 1, "P2": 2, "P3": 3} + + +class ExclusionCheckResult: + """排除检查结果。 + + Attributes: + matched: 是否命中 + rule_id: 命中的规则ID + rule_name: 命中的规则名称 + match_type: 匹配方式 + matched_detail: 命中详情 + action_type: 命中后动作类型 + transfer_message: 转人工提示语 + """ + + def __init__( + self, + matched: bool = False, + rule_id: str = "", + rule_name: str = "", + match_type: str = "", + matched_detail: str = "", + action_type: str = "", + transfer_message: str = "", + ): + self.matched = matched + self.rule_id = rule_id + self.rule_name = rule_name + self.match_type = match_type + self.matched_detail = matched_detail + self.action_type = action_type + self.transfer_message = transfer_message + + +class ExclusionService: + """代答排除匹配引擎。 + + 责任链调度: + 1. 查询所有启用的排除规则,按优先级排序(P0 > P1 > P2 > P3) + 2. 依次调用对应 Matcher 进行匹配 + 3. 命中即停止,记录 exclusion_logs,更新 hit_count + 4. 返回命中结果(含 action_type) + """ + + async def check_exclusions( + self, + db: AsyncSession, + message: str, + conversation_id: str, + user_id: str, + ) -> ExclusionCheckResult: + """检查消息是否命中排除规则。 + + Args: + db: 数据库会话 + message: 用户消息文本 + conversation_id: 会话ID + user_id: 用户ID + + Returns: + ExclusionCheckResult: 检查结果 + """ + # 查询所有启用的规则 + result = await db.execute( + select(ExclusionRule) + .where(ExclusionRule.status == "enabled") + .order_by(ExclusionRule.priority, ExclusionRule.created_at) + ) + rules = result.scalars().all() + + if not rules: + return ExclusionCheckResult(matched=False) + + # 按优先级排序(P0 > P1 > P2 > P3) + rules_sorted = sorted( + rules, + key=lambda r: _PRIORITY_ORDER.get(r.priority, 99), + ) + + # 构建上下文 + context: Dict[str, Any] = { + "conversation_id": conversation_id, + "user_id": user_id, + "db": db, + } + + # 责任链:依次调用对应 Matcher + for rule in rules_sorted: + matcher = MATCHER_REGISTRY.get(rule.match_type) + if matcher is None: + logger.warning("未知匹配类型: %s, rule_id=%s", rule.match_type, rule.id) + continue + + try: + match_result: MatchResult = await matcher.match( + message=message, + condition=rule.match_condition, + context=context, + ) + except Exception as e: + logger.error( + "匹配器异常: rule=%s, type=%s, error=%s", + rule.rule_name, rule.match_type, e, + ) + continue + + if match_result.matched: + # 命中!记录日志、更新 hit_count + await self._log_hit( + db=db, + rule=rule, + message=message, + conversation_id=conversation_id, + user_id=user_id, + match_result=match_result, + ) + + logger.info( + "排除规则命中: rule=%s, type=%s, detail=%s, action=%s", + rule.rule_name, rule.match_type, + match_result.matched_detail, rule.action_type, + ) + + return ExclusionCheckResult( + matched=True, + rule_id=rule.id, + rule_name=rule.rule_name, + match_type=rule.match_type, + matched_detail=match_result.matched_detail, + action_type=rule.action_type, + transfer_message=rule.transfer_message or "", + ) + + return ExclusionCheckResult(matched=False) + + async def test_match( + self, + db: AsyncSession, + message: str, + rule_id: Optional[str] = None, + ) -> ExclusionCheckResult: + """测试匹配(管理后台用,不记录日志、不更新 hit_count)。 + + Args: + db: 数据库会话 + message: 测试消息文本 + rule_id: 指定规则ID(可选,不指定则测试所有启用规则) + + Returns: + ExclusionCheckResult: 测试结果 + """ + if rule_id: + # 测试指定规则 + result = await db.execute( + select(ExclusionRule).where(ExclusionRule.id == rule_id) + ) + rule = result.scalar_one_or_none() + if not rule: + return ExclusionCheckResult(matched=False) + rules_to_test = [rule] + else: + # 测试所有启用规则 + result = await db.execute( + select(ExclusionRule) + .where(ExclusionRule.status == "enabled") + .order_by(ExclusionRule.priority) + ) + rules_to_test = result.scalars().all() + + rules_sorted = sorted( + rules_to_test, + key=lambda r: _PRIORITY_ORDER.get(r.priority, 99), + ) + + context: Dict[str, Any] = { + "conversation_id": "", + "user_id": "", + "db": db, + } + + for rule in rules_sorted: + matcher = MATCHER_REGISTRY.get(rule.match_type) + if matcher is None: + continue + + try: + match_result = await matcher.match( + message=message, + condition=rule.match_condition, + context=context, + ) + except Exception as e: + logger.error("测试匹配异常: rule=%s, error=%s", rule.rule_name, e) + continue + + if match_result.matched: + return ExclusionCheckResult( + matched=True, + rule_id=rule.id, + rule_name=rule.rule_name, + match_type=rule.match_type, + matched_detail=match_result.matched_detail, + action_type=rule.action_type, + transfer_message=rule.transfer_message or "", + ) + + return ExclusionCheckResult(matched=False) + + async def get_stats(self, db: AsyncSession) -> Dict[str, Any]: + """获取排除规则统计概要。 + + Args: + db: 数据库会话 + + Returns: + Dict[str, Any]: {enabled_count, disabled_count, monthly_hits, monthly_transfers} + """ + # 启用规则数 + enabled_result = await db.execute( + select(func.count()).select_from(ExclusionRule).where( + ExclusionRule.status == "enabled" + ) + ) + enabled_count = enabled_result.scalar() or 0 + + # 停用规则数 + disabled_result = await db.execute( + select(func.count()).select_from(ExclusionRule).where( + ExclusionRule.status == "disabled" + ) + ) + disabled_count = disabled_result.scalar() or 0 + + # 本月命中次数 + now = datetime.now() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + hits_result = await db.execute( + select(func.count()).select_from(ExclusionLog).where( + ExclusionLog.created_at >= month_start + ) + ) + monthly_hits = hits_result.scalar() or 0 + + # 本月转人工次数(action_type 含 transfer 的日志) + transfer_result = await db.execute( + select(func.count()).select_from(ExclusionLog).where( + and_( + ExclusionLog.created_at >= month_start, + ExclusionLog.action_type.like("transfer%"), + ) + ) + ) + monthly_transfers = transfer_result.scalar() or 0 + + return { + "enabled_count": enabled_count, + "disabled_count": disabled_count, + "monthly_hits": monthly_hits, + "monthly_transfers": monthly_transfers, + } + + async def _log_hit( + self, + db: AsyncSession, + rule: ExclusionRule, + message: str, + conversation_id: str, + user_id: str, + match_result: MatchResult, + ) -> None: + """记录命中日志并更新 hit_count。 + + Args: + db: 数据库会话 + rule: 命中的规则对象 + message: 用户消息文本 + conversation_id: 会话ID + user_id: 用户ID + match_result: 匹配结果 + """ + # 创建命中日志 + log_entry = ExclusionLog( + id=str(uuid.uuid4()), + rule_id=rule.id, + rule_name=rule.rule_name, + conversation_id=conversation_id, + user_id=user_id, + message_content=message[:2000] if message else "", + match_type=rule.match_type, + matched_detail=match_result.matched_detail, + action_type=rule.action_type, + action_result="success", + ) + db.add(log_entry) + + # 更新 hit_count + rule.hit_count = (rule.hit_count or 0) + 1 + rule.updated_at = datetime.now() + + await db.commit() + + +# 单例 +_exclusion_service: Optional[ExclusionService] = None + + +def get_exclusion_service() -> ExclusionService: + """获取 ExclusionService 单例。 + + Returns: + ExclusionService: 单例实例 + """ + global _exclusion_service + if _exclusion_service is None: + _exclusion_service = ExclusionService() + return _exclusion_service diff --git a/backend/app/services/funny_phrase_service.py b/backend/app/services/funny_phrase_service.py index 2583ef6..fbb94be 100644 --- a/backend/app/services/funny_phrase_service.py +++ b/backend/app/services/funny_phrase_service.py @@ -29,10 +29,10 @@ class FunnyPhraseService: # 默认话术(当数据库未配置时使用,和 PRD 一致) DEFAULT_PHRASES = { - "shake": "少主,这就为您去摇人,稍等...", - "keyword": "收到!这就帮您摇位大神来", + "shake": "已为您呼叫人工坐席,请稍等!", + "keyword": "已为您呼叫人工坐席,请稍等!", "waiting": "人还在路上,别急别急~", - "connected": "人摇来了!IT坐席为您服务", + "connected": "坐席正在查看您的信息,请等待处理回复!", "timeout": "坐席都在忙,不过AI还在呢,要不先聊聊?我再继续摇", "vip": "这就帮您安排专家,请稍候", } diff --git a/backend/app/services/it_health_service.py b/backend/app/services/it_health_service.py new file mode 100644 index 0000000..99849e8 --- /dev/null +++ b/backend/app/services/it_health_service.py @@ -0,0 +1,646 @@ +# ============================================================================= +# 企微IT智能服务台 — IT 健康聚合服务 +# ============================================================================= +# 说明:整合联软(设备信息/CPU/内存/硬盘)、火绒(安全状态/病毒/漏洞)、 +# 资产服务(资产编号/启用时间)数据源,返回前端 BasicInfoCard.vue 期望的数据结构。 +# +# 数据流: +# 1. 用 employee_id (企微UserID) 作为联软 strusername 查询终端 +# 2. 联软 get_dev_all_info() 获取详细硬件/磁盘/网卡信息 +# 3. 火绒 list_terminals() 按计算机名匹配,获取安全状态 +# 4. 火绒 list_terminal_leaks() 检查漏洞,get_virus_events() 检查病毒 +# 5. 资产服务 find_asset() 查资产编号和启用时间 +# +# 降级策略: +# - 联软/火绒未配置 → 返回 Mock 数据(标记 data_source: "mock") +# - 联软配置但火绒未配置 → 设备信息真实,安全状态为 pending +# - 任一API调用失败 → 该部分数据返回 None,不影响其他部分 +# ============================================================================= + +import logging +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +class ITHealthService: + """IT 健康聚合服务。 + + 从联软、火绒、资产服务获取数据,聚合为前端期望的格式。 + 所有外部 API 调用均做了异常隔离——任一数据源失败不影响整体。 + """ + + def __init__(self, db: AsyncSession): + """初始化服务。 + + Args: + db: 数据库会话(用于读取 system_configs 表中的集成配置) + """ + self.db = db + + async def get_it_health(self, employee_id: str) -> Dict[str, Any]: + """获取员工终端的 IT 健康信息。 + + 这是主入口方法,聚合所有数据源,返回前端期望的 JSON 结构。 + + Args: + employee_id: 员工企微 UserID(对应联软的 strusername) + + Returns: + Dict: 包含 current_device / other_devices / data_source / health_score + """ + # 尝试从联软获取真实设备信息 + device_info = await self._get_device_from_lianruan(employee_id) + + if device_info is None: + # 联软不可用 → 返回 Mock 数据 + return self._get_mock_data(employee_id) + + # 联软数据可用,尝试从火绒获取安全状态 + security_info = await self._get_security_from_huorong( + device_info.get("device_name", "") + ) + + # 尝试从资产服务获取资产编号 + asset_info = await self._get_asset_info(device_info.get("device_name", "")) + + # 聚合数据 + current_device = self._build_current_device(device_info, security_info, asset_info) + + # 获取其他设备(联软中该用户的其他终端) + other_devices = await self._get_other_devices(employee_id, device_info.get("device_name", "")) + + return { + "current_device": current_device, + "other_devices": other_devices, + "data_source": "real", + "generated_at": datetime.now(timezone.utc).isoformat(), + } + + # ========================================================================== + # 联软数据获取 + # ========================================================================== + + async def _get_device_from_lianruan(self, employee_id: str) -> Optional[Dict[str, Any]]: + """从联软查终端设备信息。 + + 流程: + 1. 用 employee_id 作为 strusername 查终端列表 + 2. 取第一个(最近活跃的)终端 + 3. 调 get_dev_all_info() 获取详细硬件信息 + + Args: + employee_id: 员工账号 + + Returns: + Dict: 设备信息字典,联软不可用时返回 None + """ + try: + from app.integrations.lianruan.config import get_lianruan_client + + client = await get_lianruan_client(self.db) + + # 按员工账号查终端列表 + result = await client.query_dev_by_params(strusername=employee_id, per_page=10) + terminals = result.get("items", []) + + if not terminals: + logger.info(f"联软未找到 employee_id={employee_id} 的终端") + return None + + # 取第一个终端(联软默认按最近活跃排序) + terminal = terminals[0] + device_name = terminal.strdevname + + if not device_name: + logger.warning(f"联软返回的终端无计算机名: {terminal}") + return None + + # 获取详细信息 + detail = await client.get_dev_all_info(strdevname=device_name) + + # 构建设备信息字典 + device = { + "device_name": device_name, + "is_online": terminal.istatus == "1", + "ip_address": terminal.strdevip or detail.strip1, + "mac": terminal.strmac or detail.strmac, + "os": detail.stros or "", + "location": terminal.strdeptname or "", + "department": terminal.strdeptname or "", + "switch_name": terminal.strswitchname or "", + "uptime": self._format_uptime(detail.dtdevuptime), + "last_online_time": detail.dtdevuptime or "", + "last_offline_time": detail.dtdevdowntime or "", + "device_type": detail.strdevtype or "台式机", + "serial_number": detail.strserialnumber or "", + "mainboard": detail.strmainboardtype or "", + # 硬件详情 + "cpu_list": [ + {"name": c.name, "model": c.model, "vendor": c.vendor} + for c in detail.cpu + ] if detail.cpu else [], + "memory_list": [ + {"name": m.name, "capacity": m.capacity, "vendor": m.vendor} + for m in detail.memory + ] if detail.memory else [], + "logical_disks": [ + { + "label": d.name, + "total": d.total_size, + "free": d.free_space, + "usage_percent": d.usage_percent, + } + for d in detail.logical_disk + ] if detail.logical_disk else [], + "network_cards": [ + {"name": n.name, "mac": n.mac, "is_wireless": n.is_wireless} + for n in detail.network_card + ] if detail.network_card else [], + } + + logger.info(f"联软获取设备成功: {device_name} (employee={employee_id})") + return device + + except Exception as e: + logger.warning(f"联软获取设备信息失败: {e}") + return None + + async def _get_other_devices( + self, employee_id: str, exclude_device: str + ) -> List[Dict[str, Any]]: + """获取员工的其他设备(联软中该用户的其他终端)。 + + Args: + employee_id: 员工账号 + exclude_device: 要排除的当前设备名 + + Returns: + List: 其他设备列表 + """ + try: + from app.integrations.lianruan.config import get_lianruan_client + + client = await get_lianruan_client(self.db) + result = await client.query_dev_by_params(strusername=employee_id, per_page=10) + terminals = result.get("items", []) + + other = [] + for t in terminals: + if t.strdevname and t.strdevname != exclude_device: + other.append({ + "device_type": t.strdevtype or "设备", + "device_name": t.strdevname, + "last_login_time": t.istatus == "1" and "在线" or "离线", + "last_login_location": t.strdeptname or "", + }) + + return other + + except Exception as e: + logger.warning(f"获取其他设备失败: {e}") + return [] + + # ========================================================================== + # 火绒安全数据获取 + # ========================================================================== + + async def _get_security_from_huorong( + self, computer_name: str + ) -> Optional[Dict[str, Any]]: + """从火绒查终端安全状态。 + + 流程: + 1. list_terminals() 全量分页搜索,按 computer_name 匹配 + 2. 找到后 get_terminal_detail() 获取硬件/资产/网络配置 + 3. list_terminal_leaks() 检查是否在漏洞清单中 + 4. get_virus_events() 查病毒事件统计 + + Args: + computer_name: 计算机名(联软的 strdevname) + + Returns: + Dict: 安全状态字典,火绒不可用时返回 None + """ + try: + from app.integrations.huorong.config import get_huorong_client + + client = await get_huorong_client(self.db) + + # 分页搜索终端,按计算机名匹配 + target_client_id = None + page = 1 + while page <= 10: # 最多查10页(2000台) + result = await client.list_terminals(page=page, per_page=200) + items = result.get("items", []) + + for item in items: + if item.computer_name and item.computer_name.upper() == computer_name.upper(): + target_client_id = item.client_id + break + + if target_client_id: + break + + if len(items) < 200: + break # 没有更多数据 + page += 1 + + if not target_client_id: + # 火绒中未找到该终端 → 可能未安装火绒 + return { + "huorong_installed": False, + "is_online": False, + "version": "", + "definitions": "", + "high_risk_leaks": 0, + "virus_count": 0, + "virus_uncleaned": 0, + } + + # 获取终端详情 + detail = await client.get_terminal_detail( + client_id=target_client_id, + optional_fields=["hardware", "assets", "netconf"], + ) + + # 检查漏洞清单 + leak_count = 0 + try: + leaks_result = await client.list_terminal_leaks() + for leak_item in leaks_result.get("items", []): + if leak_item.hostname and leak_item.hostname.upper() == computer_name.upper(): + leak_count = 1 # 在漏洞清单中说明有高危漏洞 + break + except Exception as e: + logger.warning(f"火绒漏洞查询失败: {e}") + + # 查病毒事件 + virus_count = 0 + virus_uncleaned = 0 + try: + virus_result = await client.get_virus_events( + client_id=target_client_id, type=0 + ) + for stat in virus_result.get("items", []): + virus_count += stat.count + if stat.result: + virus_uncleaned += stat.result.fail + stat.result.ignored + except Exception as e: + logger.warning(f"火绒病毒事件查询失败: {e}") + + return { + "huorong_installed": True, + "is_online": True, # 从 list_terminals 已确认存在 + "version": detail.computer_name and "" or "", # 火绒版本从 list 获取 + "definitions": "", + "high_risk_leaks": leak_count, + "virus_count": virus_count, + "virus_uncleaned": virus_uncleaned, + } + + except Exception as e: + logger.warning(f"火绒获取安全状态失败: {e}") + return None + + # ========================================================================== + # 资产服务 + # ========================================================================== + + async def _get_asset_info(self, device_name: str) -> Optional[Dict[str, Any]]: + """从资产服务查设备资产编号和启用时间。 + + Args: + device_name: 计算机名(用于日志,资产查询通过资产编号) + + Returns: + Dict: 资产信息(asset_tag / activate_date),不可用时返回 None + """ + try: + from app.services.asset_service import AssetService + + asset_svc = AssetService() + # 资产服务目前通过资产编号查询,设备名无法直接查 + # 这里先返回 None,后续需要联软 devassetno → 资产编号 → 查询 + # 或者资产Excel按计算机名匹配 + return None + except Exception as e: + logger.warning(f"资产服务查询失败: {e}") + return None + + # ========================================================================== + # 数据聚合 + # ========================================================================== + + def _build_current_device( + self, + device_info: Dict[str, Any], + security_info: Optional[Dict[str, Any]], + asset_info: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + """聚合联软+火绒+资产数据为前端期望的格式。 + + 前端 BasicInfoCard.vue 期望的数据结构: + - device_name / is_online / asset_tag / activate_date + - ip_address / public_ip / location / os / mac / uptime + - cpu / memory / disks (进度条) + - security_checks / compliance_checks (状态数组) + + Args: + device_info: 联软设备信息 + security_info: 火绒安全状态(可能为 None) + asset_info: 资产信息(可能为 None) + + Returns: + Dict: 前端期望的设备数据结构 + """ + # CPU 使用率(联软不提供实时使用率,用硬件型号代替) + cpu_model = "" + cpu_usage = 0 + if device_info.get("cpu_list"): + cpu = device_info["cpu_list"][0] + cpu_model = f"{cpu.get('vendor', '')} {cpu.get('name', '')}".strip() + cpu_usage = 0 # 联软不提供实时使用率 + + # 内存总量 + memory_total = "" + memory_usage = 0 + if device_info.get("memory_list"): + mem = device_info["memory_list"][0] + memory_total = mem.get("capacity", "") or "" + # 联软返回的是硬件容量,不是使用率 + + # 磁盘分区 + disks = [] + for disk in device_info.get("logical_disks", []): + try: + usage = int(float(disk.get("usage_percent", "0").replace("%", "").strip() or "0")) + except (ValueError, TypeError): + usage = 0 + + total = disk.get("total", "0") + free = disk.get("free", "0") + # 计算已用空间 + used = self._calc_used_space(total, free) + + disks.append({ + "label": f"硬盘{disk.get('label', 'C盘')}", + "usage": usage, + "used": used, + "total": total, + }) + + # 安全检查状态数组(6项) + security_checks = self._build_security_checks(security_info) + + # 合规检查状态数组(2项) + compliance_checks = self._build_compliance_checks(device_info, security_info) + + # 健康评分 + health_score = self._calc_health_score(security_checks, compliance_checks, device_info.get("is_online", False)) + + return { + "device_name": device_info.get("device_name", ""), + "is_online": device_info.get("is_online", False), + "asset_tag": asset_info.get("asset_tag", "") if asset_info else "", + "activate_date": asset_info.get("activate_date", "") if asset_info else "", + "ip_address": device_info.get("ip_address", ""), + "public_ip": "", # 公网出口IP需要额外查询 + "location": device_info.get("location", ""), + "os": device_info.get("os", ""), + "mac": device_info.get("mac", ""), + "uptime": device_info.get("uptime", ""), + "cpu": {"usage": cpu_usage, "model": cpu_model}, + "memory": {"usage": memory_usage, "total": memory_total}, + "disks": disks, + "security_checks": security_checks, + "compliance_checks": compliance_checks, + "health_score": health_score, + } + + def _build_security_checks( + self, security_info: Optional[Dict[str, Any]] + ) -> List[Dict[str, str]]: + """构建安全检查状态数组(6项)。 + + 前端期望6个检查项: + 0. 火绒安装状态 + 1. 系统补丁(高危漏洞) + 2. 高危软件 + 3. 病毒状态 + 4. 内部攻击(接入中) + 5. 网络代理(接入中) + + Args: + security_info: 火绒安全状态(可能为 None) + + Returns: + List: 6个状态对象 [{status: "pass"|"warning"|"danger"|"pending"}] + """ + if security_info is None: + # 火绒未配置 → 全部 pending + return [{"status": "pending"}] * 6 + + checks = [] + + # 0. 火绒安装 + if security_info.get("huorong_installed"): + checks.append({"status": "pass"}) + else: + checks.append({"status": "danger"}) # 未安装火绒 = 危险 + + # 1. 系统补丁(高危漏洞) + if security_info.get("high_risk_leaks", 0) > 0: + checks.append({"status": "danger"}) + else: + checks.append({"status": "pass"}) + + # 2. 高危软件(火绒不直接提供,暂返回 pass) + checks.append({"status": "pass"}) + + # 3. 病毒状态 + uncleaned = security_info.get("virus_uncleaned", 0) + if uncleaned > 0: + checks.append({"status": "danger"}) + elif security_info.get("virus_count", 0) > 0: + checks.append({"status": "warning"}) + else: + checks.append({"status": "pass"}) + + # 4. 内部攻击(接入中 — 联软尚未对接此数据源) + checks.append({"status": "pending"}) + + # 5. 网络代理(接入中 — 联软尚未对接此数据源) + checks.append({"status": "pending"}) + + return checks + + def _build_compliance_checks( + self, device_info: Dict[str, Any], security_info: Optional[Dict[str, Any]] + ) -> List[Dict[str, str]]: + """构建合规检查状态数组(2项)。 + + 前端期望2个检查项: + 0. 自备电脑检查 + 1. 未审批商业软件检查 + + Args: + device_info: 联软设备信息 + security_info: 火绒安全状态 + + Returns: + List: 2个状态对象 + """ + # 自备电脑:联软设备类型中如果有"自备"标记则 danger + device_type = device_info.get("device_type", "") + if "自备" in device_type: + return [{"status": "danger"}, {"status": "pass"}] + + # 默认通过 + return [{"status": "pass"}, {"status": "pass"}] + + def _calc_health_score( + self, + security_checks: List[Dict[str, str]], + compliance_checks: List[Dict[str, str]], + is_online: bool, + ) -> int: + """计算 IT 健康评分(0-100)。 + + 评分算法(与前端 BasicInfoCard.vue 一致): + - 安全项 danger: -15 / warning: -8 / pending: 0 + - 合规项 danger: -10 / warning: -5 + - 离线设备权重 60% + + Args: + security_checks: 安全检查状态数组 + compliance_checks: 合规检查状态数组 + is_online: 设备是否在线 + + Returns: + int: 健康评分 0-100 + """ + score = 100 + + for check in security_checks: + status = check.get("status", "pending") + if status == "danger": + score -= 15 + elif status == "warning": + score -= 8 + + for check in compliance_checks: + status = check.get("status", "pass") + if status == "danger": + score -= 10 + elif status == "warning": + score -= 5 + + if not is_online: + score = round(score * 0.6) + + return max(0, score) + + # ========================================================================== + # 工具方法 + # ========================================================================== + + def _format_uptime(self, last_online_time: str) -> str: + """格式化运行时长。 + + 联软返回的是最近上线时间字符串,计算距现在的时长。 + 如果无法解析则返回空字符串。 + + Args: + last_online_time: 联软返回的上线时间字符串 + + Returns: + str: 如 "12天3小时" 或空字符串 + """ + if not last_online_time: + return "" + + try: + # 联软时间格式可能是 "2026-07-12 08:30:00" 或类似 + dt = datetime.strptime(last_online_time.replace("T", " "), "%Y-%m-%d %H:%M:%S") + now = datetime.now() + delta = now - dt + + days = delta.days + hours = delta.seconds // 3600 + + if days > 0: + return f"{days}天{hours}小时" + else: + minutes = delta.seconds // 60 + return f"{minutes}分钟" + except (ValueError, TypeError): + return "" + + def _calc_used_space(self, total: str, free: str) -> str: + """计算已用空间。 + + Args: + total: 总容量字符串(如 "256GB") + free: 可用空间字符串(如 "86GB") + + Returns: + str: 已用空间(如 "170GB") + """ + try: + # 尝试提取数字部分 + total_num = float("".join(c for c in total if c.isdigit() or c == ".")) + free_num = float("".join(c for c in free if c.isdigit() or c == ".")) + + used_num = total_num - free_num + if used_num < 0: + used_num = 0 + + # 保留单位 + unit = "".join(c for c in total if c.isalpha()) + if unit: + return f"{int(used_num)}{unit}" + return str(int(used_num)) + except (ValueError, TypeError): + return "" + + # ========================================================================== + # Mock 数据(联软/火绒未配置时降级) + # ========================================================================== + + def _get_mock_data(self, employee_id: str) -> Dict[str, Any]: + """返回 Mock 数据(联软不可用时降级)。 + + Args: + employee_id: 员工ID(用于日志) + + Returns: + Dict: 与真实数据结构一致的 Mock 数据 + """ + return { + "current_device": { + "device_name": "DESKTOP-MOCK", + "is_online": True, + "asset_tag": "", + "activate_date": "", + "ip_address": "10.90.5.x", + "public_ip": "218.75.34.87", + "location": "待获取", + "os": "待获取", + "mac": "", + "uptime": "", + "cpu": {"usage": 0, "model": ""}, + "memory": {"usage": 0, "total": ""}, + "disks": [], + "security_checks": [{"status": "pending"}] * 6, + "compliance_checks": [{"status": "pass"}, {"status": "pass"}], + "health_score": 100, + }, + "other_devices": [], + "data_source": "mock", + "generated_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/backend/app/services/matchers/__init__.py b/backend/app/services/matchers/__init__.py new file mode 100644 index 0000000..8cf03d8 --- /dev/null +++ b/backend/app/services/matchers/__init__.py @@ -0,0 +1,33 @@ +# ============================================================================= +# 企微IT智能服务台 — 代答排除匹配器包 +# ============================================================================= +# 说明:策略模式实现 4 种匹配器,由 ExclusionService 责任链调度。 +# 1. KeywordMatcher — 关键词匹配(逗号分隔,包含任一即命中) +# 2. RegexMatcher — 正则匹配(编译缓存 + ReDoS 超时保护) +# 3. IntentMatcher — 意图匹配(复用审批意图识别 Dify 链路) +# 4. CategoryMatcher — 分类匹配(查 triage_sessions 获取 problem_category) +# ============================================================================= + +from app.services.matchers.base import BaseMatcher, MatchResult +from app.services.matchers.keyword_matcher import KeywordMatcher +from app.services.matchers.regex_matcher import RegexMatcher +from app.services.matchers.intent_matcher import IntentMatcher +from app.services.matchers.category_matcher import CategoryMatcher + +# 匹配器注册表:match_type → Matcher 实例 +MATCHER_REGISTRY: dict[str, BaseMatcher] = { + "keyword": KeywordMatcher(), + "regex": RegexMatcher(), + "intent": IntentMatcher(), + "category": CategoryMatcher(), +} + +__all__ = [ + "BaseMatcher", + "MatchResult", + "KeywordMatcher", + "RegexMatcher", + "IntentMatcher", + "CategoryMatcher", + "MATCHER_REGISTRY", +] diff --git a/backend/app/services/matchers/base.py b/backend/app/services/matchers/base.py new file mode 100644 index 0000000..90fe1c1 --- /dev/null +++ b/backend/app/services/matchers/base.py @@ -0,0 +1,55 @@ +# ============================================================================= +# 企微IT智能服务台 — 匹配器基类 + 匹配结果 +# ============================================================================= +# 说明:策略模式接口定义,所有匹配器必须继承 BaseMatcher 并实现 match 方法。 +# ============================================================================= + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class MatchResult: + """匹配结果。 + + Attributes: + matched: 是否命中 + matched_detail: 命中的关键词/正则/意图/分类(用于日志和测试展示) + match_position: 匹配位置(用于测试展示,如 "位置 12-18") + """ + + matched: bool + matched_detail: str = "" + match_position: str = "" + + +class BaseMatcher(ABC): + """匹配器基类 — 策略模式接口。 + + 每种匹配器实现一种匹配逻辑(关键词/正则/意图/分类), + 由 ExclusionService 责任链按优先级依次调用。 + """ + + @abstractmethod + async def match( + self, + message: str, + condition: str, + context: Optional[dict] = None, + ) -> MatchResult: + """检查消息是否匹配规则条件。 + + Args: + message: 用户消息文本 + condition: 匹配条件 + - keyword: 逗号分隔关键词列表(如 "密码过期,账号锁定") + - regex: 正则表达式(如 "密码.*过期") + - intent: 逗号分隔意图ID列表(如 "password_reset,account_unlock") + - category: 逗号分隔分类名称列表(如 "Outlook,VPN") + context: 上下文字典,可含 conversation_id, user_id, db 等 + + Returns: + MatchResult: 匹配结果 + """ + ... diff --git a/backend/app/services/matchers/category_matcher.py b/backend/app/services/matchers/category_matcher.py new file mode 100644 index 0000000..d4588f1 --- /dev/null +++ b/backend/app/services/matchers/category_matcher.py @@ -0,0 +1,98 @@ +# ============================================================================= +# 企微IT智能服务台 — 分类匹配器 +# ============================================================================= +# 说明:查询 triage_sessions 表获取分诊结果的 problem_category, +# 检查是否在排除分类列表中。 +# 软依赖:无分诊结果时返回未命中,不影响其他匹配器执行。 +# ============================================================================= + +import logging +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.triage_session import TriageSession +from app.services.matchers.base import BaseMatcher, MatchResult + +logger = logging.getLogger(__name__) + + +class CategoryMatcher(BaseMatcher): + """分类匹配器。 + + 匹配逻辑: + 1. 从 context 中获取 conversation_id 和 db + 2. 查询 triage_sessions 获取最近一条分诊记录的 problem_category + 3. 检查 problem_category 是否在排除分类列表中 + + 软依赖: + - 无 conversation_id → 返回未命中 + - 无 db → 返回未命中 + - 无分诊记录 → 返回未命中 + - 分诊记录无 problem_category → 返回未命中 + + Example: + condition = "Outlook,VPN,打印机" + conversation_id = "conv-123" + → 查到最近分诊记录 problem_category = "Outlook" + → 命中,matched_detail="分类: Outlook" + """ + + async def match( + self, + message: str, + condition: str, + context: Optional[dict] = None, + ) -> MatchResult: + """检查分诊分类是否在排除列表中。 + + Args: + message: 用户消息文本(本匹配器不直接使用,保留接口一致性) + condition: 逗号分隔的分类名称列表 + context: 上下文,需含 conversation_id 和 db + + Returns: + MatchResult: 命中时 matched=True, matched_detail="分类: xxx" + """ + if not condition or not context: + return MatchResult(matched=False) + + conversation_id = context.get("conversation_id") + db: Optional[AsyncSession] = context.get("db") + + if not conversation_id or not db: + return MatchResult(matched=False) + + excluded_categories = [s.strip() for s in condition.split(",") if s.strip()] + if not excluded_categories: + return MatchResult(matched=False) + + # 查询最近一条分诊记录 + try: + result = await db.execute( + select(TriageSession) + .where(TriageSession.conversation_id == conversation_id) + .order_by(TriageSession.created_at.desc()) + .limit(1) + ) + triage = result.scalar_one_or_none() + except Exception as e: + logger.error("分类匹配器查询分诊记录失败: %s", e) + return MatchResult(matched=False) + + if not triage or not triage.problem_category: + # 无分诊结果,软依赖跳过 + return MatchResult(matched=False) + + # 检查分类是否在排除列表中 + category = triage.problem_category + for excluded in excluded_categories: + if excluded.lower() == category.lower(): + return MatchResult( + matched=True, + matched_detail=f"分类: {category}", + match_position=f"分诊分类匹配(triage_id={triage.id})", + ) + + return MatchResult(matched=False) diff --git a/backend/app/services/matchers/intent_matcher.py b/backend/app/services/matchers/intent_matcher.py new file mode 100644 index 0000000..f76f6e7 --- /dev/null +++ b/backend/app/services/matchers/intent_matcher.py @@ -0,0 +1,142 @@ +# ============================================================================= +# 企微IT智能服务台 — 意图匹配器 +# ============================================================================= +# 说明:复用审批意图识别 Dify 链路(approval_dify_base_url + approval_dify_api_key), +# 调用 Dify 意图识别 API,检查返回意图是否在排除列表中。 +# 降级处理:Dify 不可用时返回未命中(不影响其他匹配器执行)。 +# ============================================================================= + +import logging +from typing import Optional + +import httpx + +from app.config import settings +from app.services.matchers.base import BaseMatcher, MatchResult + +logger = logging.getLogger(__name__) + +# 意图识别 System Prompt +_INTENT_SYSTEM_PROMPT = ( + "你是IT服务台意图识别引擎,负责分析用户消息的意图类别。\n" + "输出约束:只输出意图ID(一个词),不要输出解释性文字。\n" + "常见意图ID包括:password_reset, account_unlock, software_install, " + "network_issue, hardware_repair, vpn_issue, email_issue, " + "approval_request, information_inquiry, complaint, other." +) + + +class IntentMatcher(BaseMatcher): + """意图匹配器。 + + 匹配逻辑: + 1. 调用 Dify 意图识别 API(复用审批意图链路) + 2. 获取用户消息的意图ID + 3. 检查意图ID是否在排除列表中 + + 降级处理: + - Dify 未配置或不可用 → 返回未命中 + - Dify 超时 → 返回未命中 + - 返回格式异常 → 返回未命中 + + Example: + condition = "password_reset,account_unlock" + message = "我的密码忘了,帮我重置一下" + → Dify 返回 "password_reset" + → 命中,matched_detail="意图: password_reset" + """ + + async def _recognize_intent(self, message: str) -> Optional[str]: + """调用 Dify 意图识别 API。 + + Args: + message: 用户消息文本 + + Returns: + Optional[str]: 识别到的意图ID,失败返回 None + """ + api_url = settings.approval_dify_base_url + api_key = settings.approval_dify_api_key + timeout = settings.approval_dify_timeout + + if not api_url or not api_key: + logger.warning("审批意图识别 Dify 未配置,跳过意图匹配") + return None + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post( + f"{api_url}/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "intent-recognition", + "messages": [ + {"role": "system", "content": _INTENT_SYSTEM_PROMPT}, + {"role": "user", "content": message}, + ], + "temperature": 0.1, + "max_tokens": 50, + }, + ) + resp.raise_for_status() + + resp_data = resp.json() + content = resp_data["choices"][0]["message"]["content"].strip() + + # 清理可能的 markdown 包裹 + if content.startswith("```"): + content = content.strip("`").strip() + + logger.info("意图识别结果: message=%s, intent=%s", message[:50], content) + return content + + except httpx.TimeoutException: + logger.warning("意图识别 Dify 请求超时(%s秒)", timeout) + return None + except Exception as e: + logger.error("意图识别 Dify 调用异常: %s", e) + return None + + async def match( + self, + message: str, + condition: str, + context: Optional[dict] = None, + ) -> MatchResult: + """检查消息意图是否在排除列表中。 + + Args: + message: 用户消息文本 + condition: 逗号分隔的意图ID列表 + context: 上下文(本匹配器不需要) + + Returns: + MatchResult: 命中时 matched=True, matched_detail="意图: xxx" + """ + if not message or not condition: + return MatchResult(matched=False) + + excluded_intents = [s.strip() for s in condition.split(",") if s.strip()] + if not excluded_intents: + return MatchResult(matched=False) + + # 调用 Dify 意图识别 + intent = await self._recognize_intent(message) + if intent is None: + # Dify 不可用,降级返回未命中 + return MatchResult(matched=False) + + # 检查意图是否在排除列表中(不区分大小写) + intent_lower = intent.lower() + for excluded in excluded_intents: + if excluded.lower() == intent_lower: + return MatchResult( + matched=True, + matched_detail=f"意图: {intent}", + match_position="意图识别匹配", + ) + + return MatchResult(matched=False) diff --git a/backend/app/services/matchers/keyword_matcher.py b/backend/app/services/matchers/keyword_matcher.py new file mode 100644 index 0000000..bd361dd --- /dev/null +++ b/backend/app/services/matchers/keyword_matcher.py @@ -0,0 +1,67 @@ +# ============================================================================= +# 企微IT智能服务台 — 关键词匹配器 +# ============================================================================= +# 说明:逗号分隔关键词列表,消息包含任一关键词即命中。 +# 匹配不区分大小写,支持中英文混合。 +# ============================================================================= + +import logging +from typing import Optional + +from app.services.matchers.base import BaseMatcher, MatchResult + +logger = logging.getLogger(__name__) + + +class KeywordMatcher(BaseMatcher): + """关键词匹配器。 + + 匹配逻辑: + 1. 将 condition 按逗号分隔为关键词列表 + 2. 对消息文本做小写化处理 + 3. 消息包含任一关键词(小写化后)即命中 + + Example: + condition = "密码过期,账号锁定,密码错误" + message = "我的密码过期了怎么办" + → 命中关键词 "密码过期" + """ + + async def match( + self, + message: str, + condition: str, + context: Optional[dict] = None, + ) -> MatchResult: + """检查消息是否包含任一关键词。 + + Args: + message: 用户消息文本 + condition: 逗号分隔的关键词列表 + context: 上下文(本匹配器不需要) + + Returns: + MatchResult: 命中时 matched=True, matched_detail=命中的关键词 + """ + if not message or not condition: + return MatchResult(matched=False) + + # 按逗号分隔关键词,去除空白 + keywords = [kw.strip() for kw in condition.split(",") if kw.strip()] + if not keywords: + return MatchResult(matched=False) + + # 消息小写化用于不区分大小写匹配 + message_lower = message.lower() + + for kw in keywords: + kw_lower = kw.lower() + pos = message_lower.find(kw_lower) + if pos != -1: + return MatchResult( + matched=True, + matched_detail=f"关键词: {kw}", + match_position=f"位置 {pos}-{pos + len(kw)}", + ) + + return MatchResult(matched=False) diff --git a/backend/app/services/matchers/regex_matcher.py b/backend/app/services/matchers/regex_matcher.py new file mode 100644 index 0000000..e8fc34b --- /dev/null +++ b/backend/app/services/matchers/regex_matcher.py @@ -0,0 +1,125 @@ +# ============================================================================= +# 企微IT智能服务台 — 正则匹配器 +# ============================================================================= +# 说明:使用 Python re.search 进行正则匹配,带编译缓存和 ReDoS 超时保护。 +# 编译缓存:同一 pattern 只编译一次,缓存在类变量 _compile_cache 中。 +# ReDoS 保护:使用 signal.alarm 超时机制,防止恶意正则导致 CPU 打满。 +# ============================================================================= + +import logging +import re +import signal +from typing import Optional + +from app.services.matchers.base import BaseMatcher, MatchResult + +logger = logging.getLogger(__name__) + +# 正则匹配超时时间(秒),防止 ReDoS +_REGEX_TIMEOUT_SEC: float = 2.0 + +# 正则编译缓存最大条目数 +_MAX_CACHE_SIZE: int = 200 + + +class RegexMatcher(BaseMatcher): + """正则匹配器。 + + 匹配逻辑: + 1. 编译 condition 为正则 Pattern(带缓存) + 2. 在消息文本中搜索匹配 + 3. 命中则返回匹配详情和位置 + + 安全措施: + - 正则编译缓存:同一 pattern 只编译一次 + - ReDoS 超时保护:匹配超过 2 秒自动中断,返回未命中 + + Example: + condition = "密码.*过期" + message = "我的密码好像过期了" + → 命中,matched_detail="密码好像过期" + """ + + # 类级正则编译缓存:pattern_str → compiled Pattern + _compile_cache: dict[str, re.Pattern] = {} + + def _get_compiled(self, pattern_str: str) -> Optional[re.Pattern]: + """获取编译后的正则 Pattern(带缓存)。 + + Args: + pattern_str: 正则表达式字符串 + + Returns: + Optional[re.Pattern]: 编译后的 Pattern,编译失败返回 None + """ + # 缓存命中 + if pattern_str in self._compile_cache: + return self._compile_cache[pattern_str] + + # 缓存清理:超过上限时清空(简单 LRU 策略) + if len(self._compile_cache) >= _MAX_CACHE_SIZE: + self._compile_cache.clear() + + # 编译正则 + try: + compiled = re.compile(pattern_str, re.IGNORECASE | re.MULTILINE) + self._compile_cache[pattern_str] = compiled + return compiled + except re.error as e: + logger.warning("正则编译失败: pattern=%s, error=%s", pattern_str, e) + return None + + @staticmethod + def _timeout_handler(signum, frame): + """正则匹配超时信号处理器。""" + raise TimeoutError("Regex matching timed out (possible ReDoS)") + + async def match( + self, + message: str, + condition: str, + context: Optional[dict] = None, + ) -> MatchResult: + """检查消息是否匹配正则表达式。 + + Args: + message: 用户消息文本 + condition: 正则表达式字符串 + context: 上下文(本匹配器不需要) + + Returns: + MatchResult: 命中时 matched=True, matched_detail=匹配到的文本 + """ + if not message or not condition: + return MatchResult(matched=False) + + compiled = self._get_compiled(condition) + if compiled is None: + return MatchResult(matched=False) + + # 使用 signal 超时保护(仅 Unix 平台可用,Windows 降级为无超时) + try: + # 设置超时信号 + old_handler = signal.signal(signal.SIGALRM, self._timeout_handler) + signal.setitimer(signal.ITIMER_REAL, _REGEX_TIMEOUT_SEC) + try: + m = compiled.search(message) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, old_handler) + except (TimeoutError, OSError): + logger.warning("正则匹配超时(ReDoS 保护): pattern=%s", condition) + return MatchResult(matched=False) + except Exception as e: + logger.error("正则匹配异常: pattern=%s, error=%s", condition, e) + return MatchResult(matched=False) + + if m: + matched_text = m.group(0) + return MatchResult( + matched=True, + matched_detail=f"正则匹配: {matched_text}", + match_position=f"位置 {m.start()}-{m.end()}", + ) + + return MatchResult(matched=False) diff --git a/backend/app/services/meetingroom_service.py b/backend/app/services/meetingroom_service.py index 1e325dc..f13d60e 100644 --- a/backend/app/services/meetingroom_service.py +++ b/backend/app/services/meetingroom_service.py @@ -510,3 +510,50 @@ class MeetingroomService: if dt: result[field] = dt.isoformat() return result + + # ========================================================================== + # 操作指南查询 + # ========================================================================== + + @staticmethod + async def get_guides( + db, + category: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """获取操作指南列表。 + + 从数据库查询启用的操作指南,按 sort_order 排序。 + 可按设备类型过滤。 + + Args: + db: 数据库会话 + category: 设备类型过滤(可选) + + Returns: + List[Dict[str, Any]]: 指南列表 + """ + from sqlalchemy import select as sa_select + from app.models.meetingroom_guide import MeetingroomGuide + + stmt = ( + sa_select(MeetingroomGuide) + .where(MeetingroomGuide.is_active == True) # noqa: E712 + .order_by(MeetingroomGuide.sort_order, MeetingroomGuide.id) + ) + if category: + stmt = stmt.where(MeetingroomGuide.category == category) + + result = await db.execute(stmt) + guides = result.scalars().all() + + return [ + { + "id": g.id, + "category": g.category, + "title": g.title, + "brief": g.brief, + "detail_url": g.detail_url, + "icon": g.icon, + } + for g in guides + ] diff --git a/backend/app/services/queue_service.py b/backend/app/services/queue_service.py new file mode 100644 index 0000000..34eb2dc --- /dev/null +++ b/backend/app/services/queue_service.py @@ -0,0 +1,482 @@ +# ============================================================================= +# 企微IT智能服务台 — 分层排队服务 +# ============================================================================= +# 说明:实现三段排序的排队位置计算和平台统计 +# +# 排队三段排序(决策 C1/C2): +# 段1(VIP):is_vip = true,不受信息梳理影响 +# 段2(已梳理):is_vip = false AND info_locked = true +# 段3(待梳理):is_vip = false AND info_locked = false +# +# 段内排序:queue_priority DESC → urgency_score DESC → created_at ASC +# 插队规则(决策 C4):queue_priority = min(答题数//3, 2),上限2 +# ============================================================================= + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import and_, func, or_, select, case +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.conversation import Conversation +from app.models.quiz import EmployeePoints + +logger = logging.getLogger(__name__) + +# 预估每个排队者的服务时间(秒),用于计算预估等待时间 +ESTIMATED_SERVICE_TIME_SEC = 300 # 5分钟/人 + + +class QueueService: + """分层排队服务。 + + 提供排队位置计算、平台统计、坐席端看板数据等功能。 + """ + + # ====================================================================== + # 段位判定 + # ====================================================================== + + @staticmethod + def _determine_segment(conversation: Conversation) -> str: + """判定会话属于哪个排队段位。 + + Args: + conversation: 会话对象 + + Returns: + str: "vip" / "completed" / "incomplete" + """ + if conversation.is_vip: + return "vip" + elif conversation.info_locked: + return "completed" + else: + return "incomplete" + + # ====================================================================== + # 排队位置计算 + # ====================================================================== + + async def calculate_queue_position( + self, db: AsyncSession, conversation: Conversation + ) -> Dict[str, Any]: + """计算指定会话的排队位置(三段排序)。 + + 排序逻辑: + 1. VIP段排最前 + 2. 已梳理(info_locked=true)段排第二 + 3. 待梳理(info_locked=false)段排最后 + 4. 同段内:queue_priority DESC → urgency_score DESC → created_at ASC + + Args: + db: 数据库会话 + conversation: 要计算位置的会话 + + Returns: + Dict: {position, segment, ahead_count, estimated_wait_sec} + """ + segment = self._determine_segment(conversation) + ahead_count = 0 + + # ---- 计算更高段的人数 ---- + if segment != "vip": + # 当前不是VIP段 → 所有VIP都排前面 + vip_count = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status == "queued", + Conversation.is_vip == True, # noqa: E712 + ) + ) + ahead_count += vip_count or 0 + + if segment == "incomplete": + # 当前是待梳理段 → 已梳理段也排前面 + completed_count = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status == "queued", + Conversation.is_vip == False, # noqa: E712 + Conversation.info_locked == True, # noqa: E712 + ) + ) + ahead_count += completed_count or 0 + + # ---- 计算同段内排在前面的人数 ---- + same_segment_ahead = await self._count_same_segment_ahead(db, conversation, segment) + ahead_count += same_segment_ahead + + position = ahead_count + 1 + estimated_wait = position * ESTIMATED_SERVICE_TIME_SEC + + # 中文段位名称(前端展示用) + segment_labels = { + "vip": "VIP优先", + "completed": "已梳理", + "incomplete": "待梳理", + } + + return { + "position": position, + "segment": segment, + "segment_label": segment_labels.get(segment, segment), + "ahead_count": ahead_count, + "estimated_wait_sec": estimated_wait, + "estimated_wait_text": self._format_wait_time(estimated_wait), + "queue_priority": conversation.queue_priority, + } + + async def _count_same_segment_ahead( + self, db: AsyncSession, conversation: Conversation, segment: str + ) -> int: + """计算同段内排在当前会话前面的排队人数。 + + 段内排序规则:queue_priority DESC → urgency_score DESC → created_at ASC + + Args: + db: 数据库会话 + conversation: 当前会话 + segment: 当前段位 + + Returns: + int: 同段内排在前面的人数 + """ + # 构建同段条件 + conditions = [ + Conversation.status == "queued", + Conversation.id != conversation.id, # 排除自己 + ] + + if segment == "vip": + conditions.append(Conversation.is_vip == True) # noqa: E712 + elif segment == "completed": + conditions.append(Conversation.is_vip == False) # noqa: E712 + conditions.append(Conversation.info_locked == True) # noqa: E712 + else: # incomplete + conditions.append(Conversation.is_vip == False) # noqa: E712 + conditions.append(Conversation.info_locked == False) # noqa: E712 + + # 同段内排在前面的条件: + # 1. queue_priority 更高 + # 2. 或 queue_priority 相同且 urgency_score 更高 + # 3. 或 queue_priority 和 urgency_score 都相同且 created_at 更早 + ahead_conditions = or_( + Conversation.queue_priority > conversation.queue_priority, + and_( + Conversation.queue_priority == conversation.queue_priority, + Conversation.urgency_score > conversation.urgency_score, + ), + and_( + Conversation.queue_priority == conversation.queue_priority, + Conversation.urgency_score == conversation.urgency_score, + Conversation.created_at < conversation.created_at, + ), + ) + + count = await db.scalar( + select(func.count(Conversation.id)).where( + *conditions, ahead_conditions + ) + ) + return count or 0 + + # ====================================================================== + # 平台统计 + # ====================================================================== + + async def get_platform_stats(self, db: AsyncSession) -> Dict[str, int]: + """获取平台统计数据(决策 C5)。 + + total_active = ai_handling + queued + serving + queued = 排队中人数 + serving = 服务中人数 + + Args: + db: 数据库会话 + + Returns: + Dict: {total_active, queued, serving, ai_handling} + """ + # 总活跃 = ai_handling + queued + serving + total_active = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status.in_(["ai_handling", "queued", "serving"]) + ) + ) + + queued = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status == "queued" + ) + ) + + serving = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status == "serving" + ) + ) + + ai_handling = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status == "ai_handling" + ) + ) + + return { + "total_active": total_active or 0, + "queued": queued or 0, + "serving": serving or 0, + "ai_handling": ai_handling or 0, + } + + async def get_queue_segment_stats(self, db: AsyncSession) -> Dict[str, int]: + """获取排队分段统计(坐席端看板用)。 + + Returns: + Dict: {vip_count, completed_count, incomplete_count, total_queued} + """ + # VIP段 + vip_count = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status == "queued", + Conversation.is_vip == True, # noqa: E712 + ) + ) + + # 已梳理段 + completed_count = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status == "queued", + Conversation.is_vip == False, # noqa: E712 + Conversation.info_locked == True, # noqa: E712 + ) + ) + + # 待梳理段 + incomplete_count = await db.scalar( + select(func.count(Conversation.id)).where( + Conversation.status == "queued", + Conversation.is_vip == False, # noqa: E712 + Conversation.info_locked == False, # noqa: E712 + ) + ) + + total_queued = (vip_count or 0) + (completed_count or 0) + (incomplete_count or 0) + + return { + "vip_count": vip_count or 0, + "completed_count": completed_count or 0, + "incomplete_count": incomplete_count or 0, + "total_queued": total_queued, + } + + # ====================================================================== + # 综合排队状态(H5端 queue/status API) + # ====================================================================== + + async def get_comprehensive_status( + self, db: AsyncSession, conversation: Conversation + ) -> Dict[str, Any]: + """获取综合排队状态:排队位置+段位+平台统计+答题状态+积分。 + + 供 GET /api/h5/queue/status API调用。 + + Args: + db: 数据库会话 + conversation: 当前会话 + + Returns: + Dict: 综合状态数据 + """ + # 1. 排队位置(仅排队中时计算) + if conversation.status == "queued": + queue_info = await self.calculate_queue_position(db, conversation) + else: + queue_info = { + "position": 0, + "segment": self._determine_segment(conversation), + "segment_label": "非排队中", + "ahead_count": 0, + "estimated_wait_sec": 0, + "estimated_wait_text": "—", + "queue_priority": conversation.queue_priority, + } + + # 2. 平台统计 + platform_stats = await self.get_platform_stats(db) + + # 3. 积分信息 + points_info = await self._get_employee_points(db, conversation.employee_id) + + # 4. 插队信息 + quiz_answered = conversation.queue_priority * 3 if conversation.queue_priority > 0 else 0 + max_quiz_for_jump = 6 # 2位×3题=6题 + remaining_for_next_jump = 3 - (quiz_answered % 3) if quiz_answered < max_quiz_for_jump else 0 + + return { + "conversation_status": conversation.status, + "queue": queue_info, + "platform": platform_stats, + "points": points_info, + "quiz": { + "answered_in_session": quiz_answered, + "queue_priority": conversation.queue_priority, + "max_priority": 2, + "remaining_for_next_jump": remaining_for_next_jump, + "can_jump_more": conversation.queue_priority < 2, + }, + "info_locked": conversation.info_locked, + } + + # ====================================================================== + # 坐席端排队看板 + # ====================================================================== + + async def get_agent_dashboard(self, db: AsyncSession) -> Dict[str, Any]: + """获取坐席端排队看板数据。 + + Returns: + Dict: {segment_stats, platform_stats, queue_list} + """ + segment_stats = await self.get_queue_segment_stats(db) + platform_stats = await self.get_platform_stats(db) + + # 获取排队列表(按三段排序) + queue_list = await self._get_sorted_queue_list(db, limit=50) + + return { + "segments": segment_stats, + "platform": platform_stats, + "queue_list": queue_list, + } + + async def _get_sorted_queue_list( + self, db: AsyncSession, limit: int = 50 + ) -> List[Dict[str, Any]]: + """获取按三段排序的排队列表。 + + Returns: + List[Dict]: 排队会话列表 + """ + # 查询所有排队中的会话,按段位+段内排序 + # 段位排序:VIP(0) > 已梳理(1) > 待梳理(2) + segment_order = case( + (Conversation.is_vip == True, 0), # noqa: E712 + (Conversation.info_locked == True, 1), # noqa: E712 + else_=2, + ) + + stmt = ( + select(Conversation) + .where(Conversation.status == "queued") + .order_by( + segment_order, + Conversation.queue_priority.desc(), + Conversation.urgency_score.desc(), + Conversation.created_at.asc(), + ) + .limit(limit) + ) + + result = await db.execute(stmt) + conversations = result.scalars().all() + + # 转为前端需要的列表格式 + queue_list = [] + for conv in conversations: + segment = self._determine_segment(conv) + queue_list.append({ + "conversation_id": conv.id, + "employee_name": conv.employee_name, + "department": conv.department, + "employee_id": conv.employee_id, + "segment": segment, + "segment_label": { + "vip": "VIP优先", + "completed": "已梳理", + "incomplete": "待梳理", + }.get(segment, segment), + "urgency_score": conv.urgency_score, + "queue_priority": conv.queue_priority, + "info_locked": conv.info_locked, + "is_vip": conv.is_vip, + "last_message_summary": conv.last_message_summary, + "created_at": conv.created_at.isoformat() if conv.created_at else None, + "waiting_seconds": int( + (datetime.now(timezone.utc) - conv.created_at).total_seconds() + ) if conv.created_at else 0, + }) + + return queue_list + + # ====================================================================== + # 辅助方法 + # ====================================================================== + + async def _get_employee_points( + self, db: AsyncSession, employee_id: str + ) -> Dict[str, Any]: + """获取员工积分信息。 + + Args: + db: 数据库会话 + employee_id: 员工ID + + Returns: + Dict: {total_points, level, answered_count, correct_count} + """ + result = await db.execute( + select(EmployeePoints).where(EmployeePoints.employee_id == employee_id) + ) + points = result.scalar_one_or_none() + + if points: + return { + "total_points": points.total_points, + "level": points.level, + "answered_count": points.answered_count, + "correct_count": points.correct_count, + } + else: + return { + "total_points": 0, + "level": "IT小白", + "answered_count": 0, + "correct_count": 0, + } + + @staticmethod + def _format_wait_time(seconds: int) -> str: + """将秒数格式化为人类可读的等待时间文本。 + + Args: + seconds: 秒数 + + Returns: + str: 如"约5分钟"、"约1小时30分钟" + """ + if seconds <= 0: + return "即将接通" + minutes = seconds // 60 + if minutes < 1: + return f"约{seconds}秒" + elif minutes < 60: + return f"约{minutes}分钟" + else: + hours = minutes // 60 + remaining_minutes = minutes % 60 + if remaining_minutes == 0: + return f"约{hours}小时" + return f"约{hours}小时{remaining_minutes}分钟" + + +# 单例 +_queue_service: Optional[QueueService] = None + + +def get_queue_service() -> QueueService: + """获取 QueueService 单例。""" + global _queue_service + if _queue_service is None: + _queue_service = QueueService() + return _queue_service diff --git a/backend/app/services/quiz_generation_service.py b/backend/app/services/quiz_generation_service.py new file mode 100644 index 0000000..0004423 --- /dev/null +++ b/backend/app/services/quiz_generation_service.py @@ -0,0 +1,787 @@ +# ============================================================================= +# 企微IT智能服务台 — 测验题目 AI 生成服务 +# ============================================================================= +# 说明:复用 Dify Wingman API(OpenAI-compatible 格式),自动生成: +# 1. IT 知识题(7 类别,排队等待期间向员工推送) +# 2. 诊断题(基于近期工单模式,帮助员工自检问题) +# +# 生成策略: +# - AI 生成的所有题目 is_active=False,需管理员审批后激活 +# - 种子数据(seed_quiz.py 调用)is_active=True,bootstrap 例外 +# - 定时任务每日 3:00 生成新题 + 淘汰陈旧题 +# +# 降级策略: +# - Dify 不可用时返回空结果(不抛异常),调用方决定是否重试 +# - JSON 解析三层降级:直接 parse → ```json 代码块 → [..] 提取 +# - 单题校验失败跳过,不影响其他题 +# ============================================================================= + +import json +import logging +import re +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +import httpx +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.quiz import QuizQuestion, QuizAnswer +from app.models.conversation import Conversation + +logger = logging.getLogger(__name__) + +# -------------------------------------------------------------------------- +# 常量 +# -------------------------------------------------------------------------- + +# 合法的题目类别 +VALID_CATEGORIES = {"network", "vpn", "email", "system", "printer", "security", "office"} + +# 合法的难度值 +VALID_DIFFICULTIES = {"easy", "medium", "hard"} + +# 类别中英文映射(用于 Dify prompt) +CATEGORY_MAP: Dict[str, Tuple[str, str]] = { + "network": ("网络", "局域网/WiFi/网络配置/连通性/IP分配问题"), + "vpn": ("VPN", "VPN连接/零信任aTrust/远程接入/认证失败问题"), + "email": ("邮箱", "企业邮箱/Outlook/邮件配置/收发失败问题"), + "system": ("系统", "Windows/Mac系统/蓝屏/性能优化/系统更新问题"), + "printer": ("打印机", "打印机连接/共享/驱动/扫描/卡纸问题"), + "security": ("安全", "火绒杀毒/防火墙/密码策略/钓鱼邮件/数据安全"), + "office": ("办公软件", "WPS/Office/Excel/Word/PPT/企微文档协同"), +} + +# -------------------------------------------------------------------------- +# Dify Prompt 模板 +# -------------------------------------------------------------------------- + +_KNOWLEDGE_SYSTEM_PROMPT = ( + "你是一个企业IT支持测验题目生成器。" + "你的任务是生成高质量的多选题,帮助员工在排队等待期间学习IT知识。" + "题目应贴近企业办公场景(含VPN/火绒杀毒/企微/打印机等),实用且准确。" + "必须以JSON数组格式输出,不要包含任何其他文字。" +) + +_KNOWLEDGE_USER_TEMPLATE = ( + "请生成 {count} 道关于「{category_cn}」类别的IT知识选择题。\n\n" + "要求:\n" + "1. 每题4个选项(A/B/C/D),只有1个正确答案\n" + "2. 难度分布:约40%简单、40%中等、20%困难\n" + "3. 解析要简明扼要,说明正确答案的原因\n" + "4. 题目不要重复,覆盖该类别的不同知识点\n\n" + "类别说明:{category_cn} —— {category_desc}\n\n" + "输出格式(严格JSON数组,不要markdown代码块):\n" + '[{{"question": "题目文本", ' + '"options": ["选项A", "选项B", "选项C", "选项D"], ' + '"correct_index": 0, ' + '"explanation": "解析说明", ' + '"difficulty": "medium"}}]\n\n' + "注意:correct_index 是正确选项的索引(0-3),difficulty 只能是 easy/medium/hard。" +) + +_DIAGNOSTIC_SYSTEM_PROMPT = ( + "你是一个IT故障诊断题目生成器。" + "你的任务是基于近期工单模式,生成诊断性选择题," + "帮助员工在排队期间自检问题,答案将提供给坐席参考。" + "必须以JSON数组格式输出,不要包含任何其他文字。" +) + +_DIAGNOSTIC_USER_TEMPLATE = ( + "请基于以下近期工单摘要,生成 {count} 道诊断性选择题。\n\n" + "问题类别:{problem_category}\n\n" + "近期工单摘要:\n{ticket_context}\n\n" + "要求:\n" + "1. 题目应帮助员工自检当前问题,如\"你的VPN客户端显示什么错误码?\"\n" + "2. 选项应覆盖常见情况,便于坐席快速定位问题\n" + "3. 每题4个选项,correct_index 指向最可能的选项\n" + "4. difficulty 统一为 medium\n\n" + "输出格式(严格JSON数组):\n" + '[{{"question": "诊断题目", ' + '"options": ["选项A", "选项B", "选项C", "选项D"], ' + '"correct_index": 0, ' + '"explanation": "此选项通常表示...", ' + '"difficulty": "medium"}}]' +) + + +class QuizGenerationService: + """测验题目 AI 生成服务。 + + 复用 Dify Wingman API(OpenAI-compatible 格式), + 生成知识题、诊断题,并管理陈旧题目的自动淘汰。 + + 所有 AI 生成的题目默认 is_active=False,需管理员审批。 + 种子数据调用时可通过参数设为 is_active=True。 + """ + + def __init__(self): + """初始化服务,读取 Dify API 配置。 + + 优先使用 Wingman 专用配置;若未配置则 fallback 到主 Dify API。 + """ + self.api_url = settings.dify_wingman_api_url or settings.dify_api_url + self.api_key = settings.dify_wingman_api_key or settings.dify_api_key + self.timeout = settings.dify_wingman_timeout or settings.dify_timeout + self._client: Optional[httpx.AsyncClient] = None + + # ================================================================== + # httpx 客户端管理 + # ================================================================== + + 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(self.timeout), + headers={ + "Authorization": f"Bearer {self.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 generate_knowledge_questions_batch( + self, + db: AsyncSession, + category: str, + count: int = 5, + is_active: bool = False, + ) -> Dict[str, Any]: + """批量生成知识题。 + + Args: + db: 数据库会话 + category: 题目类别(network/vpn/email/system/printer/security/office) + count: 生成数量(默认 5) + is_active: 是否直接激活(种子数据 True,定时任务 False) + + Returns: + Dict: { + "success_count": int, + "failed_count": int, + "errors": List[str], + "questions": List[Dict], # 生成的题目摘要 + } + """ + errors: List[str] = [] + questions_created: List[Dict[str, Any]] = [] + + # 校验类别 + if category not in VALID_CATEGORIES: + return { + "success_count": 0, + "failed_count": count, + "errors": [f"无效类别: {category}"], + "questions": [], + } + + # 构建并调用 Dify + category_cn, category_desc = CATEGORY_MAP[category] + user_prompt = _KNOWLEDGE_USER_TEMPLATE.format( + count=count, + category_cn=category_cn, + category_desc=category_desc, + ) + + raw_response = await self._call_dify( + system_prompt=_KNOWLEDGE_SYSTEM_PROMPT, + user_prompt=user_prompt, + temperature=0.7, # 较高温度保证多样性 + ) + + if raw_response is None: + return { + "success_count": 0, + "failed_count": count, + "errors": ["Dify API 调用失败(超时或HTTP错误)"], + "questions": [], + } + + # 解析 JSON 数组 + items = self._parse_json_array(raw_response) + if items is None: + logger.warning(f"知识题 JSON 解析失败 [{category}]: {raw_response[:200]}") + return { + "success_count": 0, + "failed_count": count, + "errors": ["AI 返回内容无法解析为 JSON 数组"], + "questions": [], + } + + # 逐条校验并插入 + success_count = 0 + failed_count = 0 + + for i, item in enumerate(items): + is_valid, err_msg, normalized = self._validate_question( + item, category, q_type="knowledge" + ) + + if not is_valid: + errors.append(f"题[{i}]: {err_msg}") + failed_count += 1 + continue + + # 去重检查 + is_dup = await self._check_duplicate( + db, normalized["question"], category + ) + if is_dup: + errors.append(f"题[{i}]: 与已有题目重复,跳过") + failed_count += 1 + continue + + # 插入数据库 + question = QuizQuestion( + type="knowledge", + category=category, + difficulty=normalized["difficulty"], + question=normalized["question"], + options=normalized["options"], + correct_index=normalized["correct_index"], + explanation=normalized["explanation"], + is_active=is_active, + created_at=datetime.now(), + ) + db.add(question) + await db.flush() # 获取 id + + questions_created.append({ + "id": question.id, + "question": question.question[:80], + "difficulty": question.difficulty, + "is_active": is_active, + }) + success_count += 1 + + logger.info( + f"知识题生成 [{category}]: 成功 {success_count}, 失败 {failed_count}" + ) + + return { + "success_count": success_count, + "failed_count": failed_count, + "errors": errors, + "questions": questions_created, + } + + async def generate_diagnostic_questions_batch( + self, + db: AsyncSession, + problem_category: str, + count: int = 3, + ticket_summaries: Optional[List[str]] = None, + is_active: bool = False, + ) -> Dict[str, Any]: + """批量生成诊断题(基于近期工单模式)。 + + Args: + db: 数据库会话 + problem_category: 问题类别(如 "vpn_disconnect") + count: 生成数量(默认 3) + ticket_summaries: 近期工单摘要列表(作为 Dify 上下文) + is_active: 是否直接激活 + + Returns: + Dict: 同 generate_knowledge_questions_batch + """ + errors: List[str] = [] + questions_created: List[Dict[str, Any]] = [] + + # 构建工单上下文 + if ticket_summaries: + ticket_context = "\n".join( + f"- {s}" for s in ticket_summaries[:20] + ) + else: + ticket_context = "(暂无近期工单数据,请基于常见问题生成)" + + # 构建并调用 Dify + user_prompt = _DIAGNOSTIC_USER_TEMPLATE.format( + count=count, + problem_category=problem_category, + ticket_context=ticket_context, + ) + + raw_response = await self._call_dify( + system_prompt=_DIAGNOSTIC_SYSTEM_PROMPT, + user_prompt=user_prompt, + temperature=0.5, # 较低温度,诊断题需要准确 + ) + + if raw_response is None: + return { + "success_count": 0, + "failed_count": count, + "errors": ["Dify API 调用失败"], + "questions": [], + } + + # 解析 JSON + items = self._parse_json_array(raw_response) + if items is None: + logger.warning(f"诊断题 JSON 解析失败 [{problem_category}]") + return { + "success_count": 0, + "failed_count": count, + "errors": ["AI 返回内容无法解析为 JSON 数组"], + "questions": [], + } + + # 逐条校验并插入 + success_count = 0 + failed_count = 0 + + # 推断诊断题的 category(从 problem_category 提取) + # problem_category 格式如 "vpn_disconnect" → category="vpn" + inferred_category = problem_category.split("_")[0] if problem_category else "system" + if inferred_category not in VALID_CATEGORIES: + inferred_category = "system" + + for i, item in enumerate(items): + is_valid, err_msg, normalized = self._validate_question( + item, inferred_category, q_type="diagnostic" + ) + + if not is_valid: + errors.append(f"诊断题[{i}]: {err_msg}") + failed_count += 1 + continue + + # 去重 + is_dup = await self._check_duplicate( + db, normalized["question"], inferred_category + ) + if is_dup: + errors.append(f"诊断题[{i}]: 重复,跳过") + failed_count += 1 + continue + + # 插入 + question = QuizQuestion( + type="diagnostic", + category=inferred_category, + problem_category=problem_category, + difficulty=normalized["difficulty"], + question=normalized["question"], + options=normalized["options"], + correct_index=normalized["correct_index"], + explanation=normalized["explanation"], + is_active=is_active, + created_at=datetime.now(), + ) + db.add(question) + await db.flush() + + questions_created.append({ + "id": question.id, + "question": question.question[:80], + "problem_category": problem_category, + "is_active": is_active, + }) + success_count += 1 + + logger.info( + f"诊断题生成 [{problem_category}]: 成功 {success_count}, 失败 {failed_count}" + ) + + return { + "success_count": success_count, + "failed_count": failed_count, + "errors": errors, + "questions": questions_created, + } + + async def deactivate_stale_questions( + self, + db: AsyncSession, + threshold: float = 0.8, + ) -> Dict[str, Any]: + """停用被过多员工答过的陈旧题目。 + + 当一道题被 >threshold 比例的活跃员工(近30天有答题记录)答过时, + 自动停用(is_active=True → False)。 + + Args: + db: 数据库会话 + threshold: 答题覆盖率阈值(0-1,默认 0.8) + + Returns: + Dict: { + "deactivated_count": int, + "total_active_employees": int, + "deactivated_questions": List[Dict], + } + """ + # 1. 统计近30天活跃员工总数 + thirty_days_ago = datetime.now() - timedelta(days=30) + total_result = await db.execute( + select(func.count(func.distinct(QuizAnswer.employee_id))).where( + QuizAnswer.created_at > thirty_days_ago + ) + ) + total_employees = total_result.scalar() or 0 + + if total_employees == 0: + logger.debug("无活跃员工答题记录,跳过陈旧题淘汰") + return { + "deactivated_count": 0, + "total_active_employees": 0, + "deactivated_questions": [], + } + + # 2. 统计每道题的答题人数 + answer_stats = await db.execute( + select( + QuizAnswer.question_id, + func.count(func.distinct(QuizAnswer.employee_id)).label("answered_count"), + ) + .where(QuizAnswer.created_at > thirty_days_ago) + .group_by(QuizAnswer.question_id) + ) + + deactivated: List[Dict[str, Any]] = [] + threshold_count = total_employees * threshold + + for row in answer_stats: + if row.answered_count >= threshold_count: + # 查询并停用该题 + result = await db.execute( + select(QuizQuestion).where( + QuizQuestion.id == row.question_id, + QuizQuestion.is_active == True, # noqa: E712 + ) + ) + question = result.scalar_one_or_none() + if question: + question.is_active = False + deactivated.append({ + "question_id": question.id, + "question_text": question.question[:80], + "answered_count": row.answered_count, + "coverage": round(row.answered_count / total_employees, 2), + }) + + await db.flush() + + logger.info( + f"陈旧题停用: {len(deactivated)} 道 " + f"(活跃员工 {total_employees} 人, 阈值 {threshold})" + ) + + return { + "deactivated_count": len(deactivated), + "total_active_employees": total_employees, + "deactivated_questions": deactivated, + } + + # ================================================================== + # 内部方法 — Dify 调用 + # ================================================================== + + async def _call_dify( + self, + system_prompt: str, + user_prompt: str, + temperature: float = 0.7, + ) -> Optional[str]: + """调用 Dify API(OpenAI-compatible 格式)。 + + Args: + system_prompt: 系统提示词 + user_prompt: 用户提示词 + temperature: 温度(0-1,越高越有创意) + + Returns: + Optional[str]: AI 返回文本,失败返回 None + """ + payload = { + "model": "Chat", + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "stream": False, + "temperature": temperature, + } + + try: + client = await self._get_client() + logger.info(f"调用 Dify 生成题目: prompt_length={len(user_prompt)}") + response = await client.post(self.api_url, json=payload) + response.raise_for_status() + data = response.json() + + # 解析 OpenAI 兼容格式返回 + choices = data.get("choices", []) + if not choices: + logger.warning("Dify API 返回空 choices") + return None + + content = choices[0]["message"]["content"] + logger.info(f"Dify API 返回: content_length={len(content)}") + return content + + except httpx.TimeoutException: + logger.error("Dify API 超时(题目生成)") + return None + except httpx.HTTPStatusError as e: + logger.error(f"Dify API HTTP 错误: status={e.response.status_code}") + return None + except Exception as e: + logger.error(f"Dify API 调用失败: {e}") + return None + + # ================================================================== + # 内部方法 — JSON 解析 + # ================================================================== + + def _parse_json_array(self, content: str) -> Optional[List[Dict[str, Any]]]: + """解析 AI 返回的 JSON 数组。 + + 三层降级解析: + 1. 直接 json.loads + 2. 提取 ```json ... ``` 代码块 + 3. 查找第一个 [ 到最后一个 ] + + Args: + content: AI 返回的原始文本 + + Returns: + Optional[List[Dict]]: 解析成功返回列表,失败返回 None + """ + if not content: + return None + + # 尝试 1:直接解析 + try: + result = json.loads(content) + if isinstance(result, list): + return result + except json.JSONDecodeError: + pass + + # 尝试 2:提取 markdown 代码块中的 JSON + json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL) + if json_match: + try: + result = json.loads(json_match.group(1).strip()) + if isinstance(result, list): + return result + except json.JSONDecodeError: + pass + + # 尝试 3:查找第一个 [ 到最后一个 ] + start = content.find('[') + end = content.rfind(']') + if start != -1 and end != -1 and end > start: + try: + result = json.loads(content[start:end + 1]) + if isinstance(result, list): + return result + except json.JSONDecodeError: + pass + + logger.warning(f"JSON 数组解析失败: {content[:200]}") + return None + + # ================================================================== + # 内部方法 — 题目校验 + # ================================================================== + + def _validate_question( + self, + item: Dict[str, Any], + category: str, + q_type: str = "knowledge", + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """校验单个题目字段。 + + 校验规则: + - question: 非空字符串,≥5 字符 + - options: 列表,恰好 4 个非空字符串 + - correct_index: 整数,0-3 范围 + - explanation: 非空字符串 + - difficulty: 枚举值 easy/medium/hard + + Args: + item: 待校验的题目字典 + category: 题目类别 + q_type: 题目类型(knowledge/diagnostic) + + Returns: + Tuple[is_valid, error_msg, normalized_data] + """ + # 1. 检查必需字段 + required_fields = {"question", "options", "correct_index", "explanation", "difficulty"} + missing = required_fields - set(item.keys()) + if missing: + return False, f"缺少字段: {missing}", None + + # 2. question 非空字符串 + question_text = item.get("question") + if not isinstance(question_text, str) or len(question_text.strip()) < 5: + return False, "question 必须是非空字符串(≥5字符)", None + + # 3. options 恰好 4 个非空字符串 + options = item.get("options") + if not isinstance(options, list) or len(options) != 4: + opt_count = len(options) if isinstance(options, list) else "非列表" + return False, f"options 必须是4个选项的列表, 实际: {opt_count}", None + + for i, opt in enumerate(options): + if not isinstance(opt, str) or not opt.strip(): + return False, f"option[{i}] 必须是非空字符串", None + + # 4. correct_index 0-3 整数 + correct_index = item.get("correct_index") + if not isinstance(correct_index, int) or correct_index < 0 or correct_index > 3: + return False, f"correct_index 必须是0-3的整数, 实际: {correct_index}", None + + # 5. difficulty 枚举 + difficulty = item.get("difficulty", "medium") + if difficulty not in VALID_DIFFICULTIES: + return False, f"difficulty 无效: {difficulty}, 应为 {VALID_DIFFICULTIES}", None + + # 6. explanation 非空 + explanation = item.get("explanation", "") + if not isinstance(explanation, str) or not explanation.strip(): + return False, "explanation 不能为空", None + + # 标准化数据 + normalized = { + "type": q_type, + "category": category, + "difficulty": difficulty, + "question": question_text.strip(), + "options": [opt.strip() for opt in options], + "correct_index": correct_index, + "explanation": explanation.strip(), + } + return True, "", normalized + + # ================================================================== + # 内部方法 — 去重检查 + # ================================================================== + + async def _check_duplicate( + self, + db: AsyncSession, + question_text: str, + category: str, + ) -> bool: + """检查题目是否重复(前 50 字符 + category 匹配)。 + + Args: + db: 数据库会话 + question_text: 题目文本 + category: 题目类别 + + Returns: + bool: True 表示已存在重复题目 + """ + # 取前 50 个字符做模糊匹配 + prefix = question_text[:50] + + result = await db.execute( + select(func.count(QuizQuestion.id)).where( + QuizQuestion.category == category, + QuizQuestion.question.like(f"{prefix}%"), + ) + ) + count = result.scalar() or 0 + return count > 0 + + # ================================================================== + # 内部方法 — 近期工单摘要 + # ================================================================== + + async def _get_recent_ticket_summaries( + self, + db: AsyncSession, + days: int = 7, + limit: int = 20, + ) -> List[Dict[str, Any]]: + """获取近期已解决工单的摘要和标签(用于诊断题生成上下文)。 + + 查询条件: + - Conversation.status == 'resolved' + - created_at > now - days + - 取 last_message_summary 和 tags + + Args: + db: 数据库会话 + days: 查询天数(默认 7) + limit: 返回数量上限(默认 20) + + Returns: + List[Dict]: [{"summary": "...", "tags": [...], "category_hint": "..."}] + """ + cutoff = datetime.now() - timedelta(days=days) + + result = await db.execute( + select( + Conversation.id, + Conversation.last_message_summary, + Conversation.tags, + ) + .where( + Conversation.status == "resolved", + Conversation.created_at > cutoff, + ) + .order_by(Conversation.created_at.desc()) + .limit(limit) + ) + + summaries: List[Dict[str, Any]] = [] + for row in result: + summary_text = row.last_message_summary or "" + # tags 是 Dict 类型,如 {"hand_raise": true, "emotion": "angry"} + tags_dict = row.tags if isinstance(row.tags, dict) else {} + tag_keys = list(tags_dict.keys()) + + # 从 tags 键名推断 category_hint + category_hint = "" + for tag_key in tag_keys: + tag_lower = tag_key.lower() + for cat in VALID_CATEGORIES: + if cat in tag_lower: + category_hint = cat + break + if category_hint: + break + + summaries.append({ + "summary": summary_text, + "tags": tag_keys, # 返回 tag 键名列表 + "category_hint": category_hint, + }) + + return summaries + + +# -------------------------------------------------------------------------- +# 单例管理 +# -------------------------------------------------------------------------- + +_quiz_gen_service: Optional[QuizGenerationService] = None + + +def get_quiz_generation_service() -> QuizGenerationService: + """获取 QuizGenerationService 单例实例。""" + global _quiz_gen_service + if _quiz_gen_service is None: + _quiz_gen_service = QuizGenerationService() + return _quiz_gen_service diff --git a/backend/app/services/quiz_service.py b/backend/app/services/quiz_service.py new file mode 100644 index 0000000..1100575 --- /dev/null +++ b/backend/app/services/quiz_service.py @@ -0,0 +1,535 @@ +# ============================================================================= +# 企微IT智能服务台 — 答题与积分服务 +# ============================================================================= +# 说明:排队等待期间的答题系统,包含双模式题目选择、积分更新、插队计算 +# +# 答题双模式(决策 D1): +# 模式A — 诊断题(info_locked=false):与当前问题相关的选择题 +# 答案附加到会话上下文,供坐席接单时参考 +# 模式B — IT知识题(info_locked=true):纯教育性质,提升IT素养 +# +# 积分规则(决策 D2): +# 答对 +10分,答错不扣分,跨会话累积 +# 5级等级:0-99 IT小白 → 100-299 IT入门 → 300-599 IT达人 → 600-999 IT专家 → 1000+ IT大师 +# +# 插队规则(决策 C4): +# queue_priority = min(答题数 // 3, 2) # 每答3题前移1位,上限2 +# ============================================================================= + +import logging +import random +from typing import Any, Dict, List, Optional + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.conversation import Conversation +from app.models.quiz import ( + EmployeePoints, + QuizAnswer, + QuizQuestion, +) +from app.utils.response import AppException + +logger = logging.getLogger(__name__) + +# 积分常量 +POINTS_PER_CORRECT = 10 +MAX_QUEUE_PRIORITY = 2 +QUIZ_PER_PRIORITY = 3 # 每答3题前移1位 + + +class QuizService: + """答题与积分服务。""" + + # ====================================================================== + # 获取下一道题 + # ====================================================================== + + async def get_next_question( + self, + db: AsyncSession, + employee_id: str, + conversation: Optional[Conversation] = None, + ) -> Dict[str, Any]: + """获取下一道题(双模式自动选择)。 + + 模式选择逻辑: + - 如果有活跃会话且 info_locked=false → 诊断题(模式A) + - 如果有活跃会话且 info_locked=true → IT知识题(模式B) + - 如果无活跃会话 → IT知识题(模式B) + + Args: + db: 数据库会话 + employee_id: 员工ID + conversation: 当前会话(可选,排队时传入) + + Returns: + Dict: 题目数据 {question_id, type, category, question, options} + """ + # 判定模式 + use_diagnostic = ( + conversation is not None + and conversation.status == "queued" + and not conversation.info_locked + ) + + if use_diagnostic: + # 模式A:诊断题 — 根据问题类别匹配 + question = await self._get_diagnostic_question(db, employee_id, conversation) + else: + # 模式B:IT知识题 + question = await self._get_knowledge_question(db, employee_id) + + if not question: + return { + "has_question": False, + "message": "暂无更多题目,请稍后再试", + } + + return { + "has_question": True, + "question_id": question.id, + "type": question.type, + "category": question.category, + "difficulty": question.difficulty, + "question": question.question, + "options": question.options, + } + + async def _get_diagnostic_question( + self, + db: AsyncSession, + employee_id: str, + conversation: Conversation, + ) -> Optional[QuizQuestion]: + """获取诊断题(模式A)。 + + 诊断题按问题类别匹配,排除已答过的题目。 + 如果没有匹配的诊断题,降级为IT知识题。 + + Args: + db: 数据库会话 + employee_id: 员工ID + conversation: 当前会话 + + Returns: + QuizQuestion 或 None + """ + # 查找已答过的题目ID(避免重复) + answered_ids_subquery = ( + select(QuizAnswer.question_id) + .where( + QuizAnswer.employee_id == employee_id, + QuizAnswer.conversation_id == conversation.id, + ) + ) + + # 查找诊断题(按问题类别匹配) + stmt = ( + select(QuizQuestion) + .where( + QuizQuestion.type == "diagnostic", + QuizQuestion.is_active == True, # noqa: E712 + QuizQuestion.id.notin_(answered_ids_subquery), + ) + .order_by(func.random()) + .limit(1) + ) + + result = await db.execute(stmt) + question = result.scalar_one_or_none() + + if question: + return question + + # 降级:如果没有匹配的诊断题,使用IT知识题 + logger.info("无诊断题可用,降级为IT知识题: employee=%s", employee_id) + return await self._get_knowledge_question(db, employee_id) + + async def _get_knowledge_question( + self, + db: AsyncSession, + employee_id: str, + ) -> Optional[QuizQuestion]: + """获取IT知识题(模式B)。 + + 排除已答过的题目,随机选取。 + + Args: + db: 数据库会话 + employee_id: 员工ID + + Returns: + QuizQuestion 或 None + """ + # 查找已答过的题目ID(跨会话排除,避免重复) + answered_ids_subquery = ( + select(QuizAnswer.question_id) + .where(QuizAnswer.employee_id == employee_id) + ) + + stmt = ( + select(QuizQuestion) + .where( + QuizQuestion.type == "knowledge", + QuizQuestion.is_active == True, # noqa: E712 + QuizQuestion.id.notin_(answered_ids_subquery), + ) + .order_by(func.random()) + .limit(1) + ) + + result = await db.execute(stmt) + question = result.scalar_one_or_none() + + if question: + return question + + # 如果所有题都答完了,重置(允许重复) + logger.info("所有题目已答完,重置题目池: employee=%s", employee_id) + stmt_all = ( + select(QuizQuestion) + .where( + QuizQuestion.type == "knowledge", + QuizQuestion.is_active == True, # noqa: E712 + ) + .order_by(func.random()) + .limit(1) + ) + result = await db.execute(stmt_all) + return result.scalar_one_or_none() + + # ====================================================================== + # 提交答案 + # ====================================================================== + + async def submit_answer( + self, + db: AsyncSession, + employee_id: str, + question_id: str, + selected_index: int, + conversation: Optional[Conversation] = None, + ) -> Dict[str, Any]: + """提交答案,返回正误+积分变化+插队效果+下一题。 + + 处理流程: + 1. 查询题目,判定正误 + 2. 记录答题(quiz_answers) + 3. 更新积分账户(employee_points) + 4. 如果在排队中,更新 queue_priority + 5. 获取下一道题 + + Args: + db: 数据库会话 + employee_id: 员工ID + question_id: 题目ID + selected_index: 员工选择的答案索引 + conversation: 当前会话(可选) + + Returns: + Dict: {is_correct, correct_index, explanation, points_earned, + total_points, level, queue_priority_changed, next_question} + """ + # 1. 查询题目 + result = await db.execute( + select(QuizQuestion).where(QuizQuestion.id == question_id) + ) + question = result.scalar_one_or_none() + if not question: + raise AppException(code=1004, message="题目不存在") + + # 2. 判定正误 + is_correct = selected_index == question.correct_index + points_earned = POINTS_PER_CORRECT if is_correct else 0 + + # 3. 记录答题 + answer = QuizAnswer( + employee_id=employee_id, + conversation_id=conversation.id if conversation else None, + question_id=question_id, + selected_index=selected_index, + is_correct=is_correct, + points_earned=points_earned, + ) + db.add(answer) + + # 4. 更新积分账户 + points_info = await self._update_employee_points( + db, employee_id, points_earned, is_correct + ) + + # 5. 如果在排队中,更新 queue_priority + queue_priority_changed = False + old_priority = 0 + new_priority = 0 + + if conversation and conversation.status == "queued": + old_priority = conversation.queue_priority + + # 计算本次会话的答题总数 + answered_count = await db.scalar( + select(func.count(QuizAnswer.id)).where( + QuizAnswer.employee_id == employee_id, + QuizAnswer.conversation_id == conversation.id, + ) + ) + answered_count = answered_count or 0 + + new_priority = min(answered_count // QUIZ_PER_PRIORITY, MAX_QUEUE_PRIORITY) + conversation.queue_priority = new_priority + conversation.updated_at = __import__("datetime").datetime.now() + + queue_priority_changed = new_priority > old_priority + + if queue_priority_changed: + logger.info( + "答题插队: employee=%s, answered=%d, priority %d→%d", + employee_id, answered_count, old_priority, new_priority + ) + + await db.commit() + + # 6. 获取下一道题 + next_question = await self.get_next_question(db, employee_id, conversation) + + # 7. 如果是诊断题且答对,将答案文本附加到会话上下文 + if (question.type == "diagnostic" and conversation and is_correct + and question.options and 0 <= selected_index < len(question.options)): + await self._append_to_conversation_context( + db, conversation, question.question, question.options[selected_index] + ) + + return { + "is_correct": is_correct, + "correct_index": question.correct_index, + "explanation": question.explanation, + "points_earned": points_earned, + "total_points": points_info["total_points"], + "level": points_info["level"], + "answered_count": points_info["answered_count"], + "correct_count": points_info["correct_count"], + "queue_priority_changed": queue_priority_changed, + "old_priority": old_priority, + "new_priority": new_priority, + "next_question": next_question, + } + + # ====================================================================== + # 积分管理 + # ====================================================================== + + async def _update_employee_points( + self, + db: AsyncSession, + employee_id: str, + points_earned: int, + is_correct: bool, + ) -> Dict[str, Any]: + """更新员工积分账户。 + + Args: + db: 数据库会话 + employee_id: 员工ID + points_earned: 本次获得积分 + is_correct: 是否答对 + + Returns: + Dict: 更新后的积分信息 + """ + result = await db.execute( + select(EmployeePoints).where(EmployeePoints.employee_id == employee_id) + ) + points = result.scalar_one_or_none() + + if points: + # 更新现有记录 + points.total_points += points_earned + points.answered_count += 1 + if is_correct: + points.correct_count += 1 + points.level = EmployeePoints.calculate_level(points.total_points) + else: + # 首次答题,创建记录 + points = EmployeePoints( + employee_id=employee_id, + total_points=points_earned, + answered_count=1, + correct_count=1 if is_correct else 0, + level=EmployeePoints.calculate_level(points_earned), + ) + db.add(points) + + await db.flush() + + return { + "total_points": points.total_points, + "level": points.level, + "answered_count": points.answered_count, + "correct_count": points.correct_count, + } + + async def get_employee_points( + self, db: AsyncSession, employee_id: str + ) -> Dict[str, Any]: + """获取员工积分信息。 + + Args: + db: 数据库会话 + employee_id: 员工ID + + Returns: + Dict: 积分信息 + """ + result = await db.execute( + select(EmployeePoints).where(EmployeePoints.employee_id == employee_id) + ) + points = result.scalar_one_or_none() + + if points: + return { + "total_points": points.total_points, + "level": points.level, + "answered_count": points.answered_count, + "correct_count": points.correct_count, + "accuracy": round(points.correct_count / max(points.answered_count, 1) * 100, 1), + } + else: + return { + "total_points": 0, + "level": "IT小白", + "answered_count": 0, + "correct_count": 0, + "accuracy": 0, + } + + # ====================================================================== + # 答题历史 + # ====================================================================== + + async def get_quiz_history( + self, + db: AsyncSession, + employee_id: str, + page: int = 1, + page_size: int = 20, + ) -> Dict[str, Any]: + """获取答题历史记录。 + + Args: + db: 数据库会话 + employee_id: 员工ID + page: 页码 + page_size: 每页数量 + + Returns: + Dict: {total, items, points} + """ + # 统计总数 + total = await db.scalar( + select(func.count(QuizAnswer.id)).where( + QuizAnswer.employee_id == employee_id + ) + ) + total = total or 0 + + # 分页查询 + offset = (page - 1) * page_size + stmt = ( + select(QuizAnswer, QuizQuestion) + .join(QuizQuestion, QuizAnswer.question_id == QuizQuestion.id) + .where(QuizAnswer.employee_id == employee_id) + .order_by(QuizAnswer.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + + result = await db.execute(stmt) + rows = result.all() + + items = [] + for answer, question in rows: + items.append({ + "answer_id": answer.id, + "question_text": question.question, + "options": question.options, + "selected_index": answer.selected_index, + "correct_index": question.correct_index, + "is_correct": answer.is_correct, + "points_earned": answer.points_earned, + "category": question.category, + "type": question.type, + "created_at": answer.created_at.isoformat() if answer.created_at else None, + }) + + # 积分信息 + points_info = await self.get_employee_points(db, employee_id) + + return { + "total": total, + "page": page, + "page_size": page_size, + "items": items, + "points": points_info, + } + + # ====================================================================== + # 诊断题答案附加到会话上下文 + # ====================================================================== + + async def _append_to_conversation_context( + self, + db: AsyncSession, + conversation: Conversation, + question_text: str, + answer_text: str, + ) -> None: + """将诊断题答案附加到会话上下文(供坐席接单时参考)。 + + 这相当于员工在排队期间做了自助信息补充。 + 答案通过 WS 推送给坐席端,坐席接单时能看到。 + + Args: + db: 数据库会话 + conversation: 当前会话 + question_text: 题目文本 + answer_text: 员工选择的答案文本 + """ + try: + from app.services.ws_manager import manager as ws_manager + + # 通过WS推送给坐席端 + ws_data = { + "type": "quiz_context_collected", + "data": { + "conversation_id": conversation.id, + "employee_id": conversation.employee_id, + "question": question_text, + "answer": answer_text, + "timestamp": __import__("datetime").datetime.now().isoformat(), + }, + } + + # 推送给坐席端(如果有分配的坐席) + if conversation.assigned_agent_id: + await ws_manager.send_to_agent(conversation.assigned_agent_id, ws_data) + + logger.info( + "诊断题答案附加到上下文: conv=%s, Q=%s, A=%s", + conversation.id, question_text[:50], answer_text[:50] + ) + except Exception as e: + logger.warning("WS推送诊断题答案失败: %s", e) + + +# 单例 +_quiz_service: Optional[QuizService] = None + + +def get_quiz_service() -> QuizService: + """获取 QuizService 单例。""" + global _quiz_service + if _quiz_service is None: + _quiz_service = QuizService() + return _quiz_service diff --git a/backend/app/services/repair_service.py b/backend/app/services/repair_service.py new file mode 100644 index 0000000..da188ac --- /dev/null +++ b/backend/app/services/repair_service.py @@ -0,0 +1,220 @@ +# ============================================================================= +# 企微IT智能服务台 — 会议室报修业务服务 +# ============================================================================= +# 说明:处理终端报修的完整流程: +# 1. 创建报修记录到 meetingroom_repair 表 +# 2. 创建IT工单会话(Conversation),状态为 queued(排队等待坐席) +# 3. 创建初始消息(故障描述),sender_type=system +# 4. 通过企微消息通知IT管理员 +# 5. 通过WS推送报修通知到坐席端 +# +# 设计决策: +# - 报修自动创建工单会话,复用现有IT服务台流程 +# - 报修人未登录时使用"匿名"身份,但仍创建工单 +# - 通知管理员和坐席同步进行,不影响主流程 +# ============================================================================= + +import logging +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.conversation import Conversation +from app.models.message import Message +from app.models.meetingroom_repair import MeetingroomRepair +from app.services.wecom_service import WecomService + +logger = logging.getLogger(__name__) + +# 设备类型中文映射 +DEVICE_TYPE_LABELS = { + "projector": "投影仪", + "video_conf": "视频会议设备", + "aircon": "空调", + "desk_chair": "桌椅", + "network": "网络", + "other": "其他设备", +} + + +class RepairService: + """会议室报修业务服务。 + + 处理终端报修的完整流程:记录 + 创建工单 + 通知。 + """ + + def __init__( + self, + db: AsyncSession, + wecom_service: Optional[WecomService] = None, + ) -> None: + """初始化报修服务。 + + Args: + db: 数据库会话 + wecom_service: 企微服务实例(用于发送通知消息) + """ + self.db = db + self.wecom = wecom_service + + async def submit_repair( + self, + terminal_sn: str, + meetingroom_id: int, + meetingroom_name: str, + device_type: str, + fault_description: str, + reporter_name: Optional[str] = None, + reporter_userid: Optional[str] = None, + ) -> dict: + """提交报修,创建工单会话并通知。 + + Args: + terminal_sn: 终端序列号 + meetingroom_id: 企微会议室ID + meetingroom_name: 会议室名称 + device_type: 故障设备类型 + fault_description: 故障描述 + reporter_name: 报修人姓名(为空则"匿名") + reporter_userid: 报修人企微userid(为空则空字符串) + + Returns: + dict: {"repair_id": int, "conversation_id": str} + """ + # 报修人信息处理 + name = reporter_name or "匿名(终端报修)" + userid = reporter_userid or "" + + # 设备类型中文名 + device_label = DEVICE_TYPE_LABELS.get(device_type, device_type) + + # 构造工单主题 + subject = f"会议室报修:{meetingroom_name} - {device_label}" + + # 1. 创建IT工单会话 + conversation_id = str(uuid.uuid4()) + conversation = Conversation( + id=conversation_id, + corp_id=settings.wecom_corp_id, + employee_id=userid or f"terminal:{terminal_sn}", + employee_name=name, + department="", + position="", + level="", + status="queued", + urgency_score=3, # 报修默认中等紧急 + tags={"source": "terminal_repair", "terminal_sn": terminal_sn, "meetingroom_id": meetingroom_id}, + last_message_at=datetime.now(), + last_message_summary=fault_description[:256], + ) + self.db.add(conversation) + + # 2. 创建初始系统消息(故障描述) + message = Message( + conversation_id=conversation_id, + sender_type="system", + sender_id="system", + sender_name="系统", + content=f"【终端报修】\n会议室: {meetingroom_name}\n设备类型: {device_label}\n故障描述: {fault_description}\n报修人: {name}\n终端SN: {terminal_sn}", + msg_type="text", + status="sent", + ) + self.db.add(message) + + # 3. 创建报修记录 + repair = MeetingroomRepair( + terminal_sn=terminal_sn, + meetingroom_id=meetingroom_id, + meetingroom_name=meetingroom_name, + device_type=device_type, + fault_description=fault_description, + reporter_name=name, + reporter_userid=userid, + conversation_id=conversation_id, + status=0, + ) + self.db.add(repair) + + # 提交事务 + await self.db.commit() + await self.db.refresh(repair) + + # 4. 异步通知IT管理员(不阻塞主流程) + try: + await self._notify_admins(subject, fault_description, meetingroom_name, name) + except Exception as e: + logger.warning(f"报修通知管理员失败(不影响主流程): {e}") + + # 5. 通过WS推送报修通知到坐席端 + try: + await self._notify_agents(subject, conversation_id, meetingroom_name) + except Exception as e: + logger.warning(f"报修WS通知坐席失败(不影响主流程): {e}") + + logger.info( + f"报修创建成功: repair_id={repair.id}, conversation_id={conversation_id}, " + f"room={meetingroom_name}, device={device_type}" + ) + + return { + "repair_id": repair.id, + "conversation_id": conversation_id, + } + + async def _notify_admins( + self, + subject: str, + description: str, + room_name: str, + reporter: str, + ) -> None: + """通过企微消息通知IT管理员。""" + if not self.wecom: + return + + # 通知内容 + content = ( + f"【会议室报修通知】\n" + f"会议室: {room_name}\n" + f"报修人: {reporter}\n" + f"问题: {description}\n" + f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n" + f"请及时处理。" + ) + + # 从配置获取管理员userid列表 + admin_userids = [] + if settings.wecom_agent_userids: + admin_userids = [uid.strip() for uid in settings.wecom_agent_userids.split(",") if uid.strip()] + + if admin_userids: + for uid in admin_userids: + try: + await self.wecom.send_text_message(uid, content) + except Exception as e: + logger.warning(f"发送报修通知给 {uid} 失败: {e}") + + async def _notify_agents( + self, + subject: str, + conversation_id: str, + room_name: str, + ) -> None: + """通过WS推送报修通知到坐席端。""" + from app.services.ws_manager import manager + + # 构造推送消息 + message_data = { + "type": "new_conversation", + "conversation_id": conversation_id, + "source": "terminal_repair", + "subject": subject, + "room_name": room_name, + "timestamp": datetime.now().isoformat(), + } + + # 广播到所有在线坐席 + await manager.broadcast(message_data) diff --git a/backend/app/services/session_service.py b/backend/app/services/session_service.py index d0f7f3b..44c2496 100644 --- a/backend/app/services/session_service.py +++ b/backend/app/services/session_service.py @@ -230,7 +230,7 @@ class SessionService: try: await self.wecom_service.send_text_message( conversation.employee_id, - "人摇来了!IT坐席为您服务", + "坐席正在查看您的信息,请等待处理回复!", ) except Exception as e: logger.warning(f"发送接入通知失败(不阻塞流程): {e}") @@ -321,6 +321,119 @@ class SessionService: return agent + # -------------------------------------------------------------------------- + # 三段排序:从排队队列中选取下一个要分配的会话(P0新增) + # -------------------------------------------------------------------------- + # 决策 C1/C2:VIP → 已梳理(info_locked=true) → 待梳理(info_locked=false) + # 段内排序:queue_priority DESC → urgency_score DESC → created_at ASC + # -------------------------------------------------------------------------- + async def pick_next_queued_conversation(self) -> Optional[Conversation]: + """从排队队列中按三段排序选取下一个要分配的会话。 + + 排序规则(决策 C1/C2): + 1. VIP 段(is_vip=true)最优先 + 2. 已梳理段(info_locked=true)次之 + 3. 待梳理段(info_locked=false)最后 + 段内排序:queue_priority DESC → urgency_score DESC → created_at ASC + + 使用 SQLAlchemy case() 表达式实现段位排序(避免多次查询)。 + + Returns: + Conversation: 排序最高的排队会话;None表示队列为空 + """ + from sqlalchemy import case, desc + + # 三段排序权重:VIP=0, 已梳理=1, 待梳理=2(值越小越优先) + segment_order = case( + (Conversation.is_vip == True, 0), + (Conversation.info_locked == True, 1), + else_=2, + ) + + stmt = ( + select(Conversation) + .where(Conversation.status == "queued") + .order_by( + segment_order.asc(), # 段位排序:VIP → 已梳理 → 待梳理 + desc(Conversation.queue_priority), # 段内:答题插队优先级 + desc(Conversation.urgency_score), # 段内:紧急度 + Conversation.created_at.asc(), # 段内:先来先服务 + ) + .limit(1) + ) + + result = await self.db.execute(stmt) + return result.scalars().first() + + # -------------------------------------------------------------------------- + # 自动分配队列中的下一个会话(坐席空闲时触发) + # -------------------------------------------------------------------------- + async def auto_assign_from_queue(self) -> Optional[Conversation]: + """从排队队列中按三段排序选取会话并分配给空闲坐席。 + + 流程: + 1. 使用 pick_next_queued_conversation 获取排序最高的排队会话 + 2. 查找空闲坐席(在线且未满负荷,按负载升序) + 3. 分配坐席,更新会话状态为 serving + 4. WS 广播通知坐席和员工 + + Returns: + Conversation: 分配成功的会话;None表示无排队会话或无空闲坐席 + """ + # 1. 获取三段排序的下一个排队会话 + conversation = await self.pick_next_queued_conversation() + if not conversation: + return None + + # 2. 查找空闲坐席 + stmt = select(Agent).where( + Agent.status == "online", + Agent.current_load < Agent.max_load + ).order_by(Agent.current_load.asc()).limit(1) + result = await self.db.execute(stmt) + agent = result.scalars().first() + + if not agent: + logger.info(f"有排队会话但无空闲坐席: conv_id={conversation.id}") + return None + + # 3. 分配 + conversation.status = "serving" + conversation.assigned_agent_id = agent.user_id + conversation.updated_at = datetime.now() + self.db.add(conversation) + + agent.current_load += 1 + self.db.add(agent) + + await self.db.flush() + + logger.info( + f"队列自动分配(三段排序): conv_id={conversation.id}, " + f"agent={agent.user_id}, " + f"vip={conversation.is_vip}, info_locked={conversation.info_locked}, " + f"queue_priority={conversation.queue_priority}" + ) + + # 4. WS 广播 + from app.services.ws_manager import manager as ws_manager + try: + await ws_manager.broadcast({ + "type": "conversation_assigned", + "data": { + "conversation_id": str(conversation.id), + "agent_id": agent.user_id, + "employee_id": conversation.employee_id, + "employee_name": conversation.employee_name, + "is_vip": conversation.is_vip, + "info_locked": conversation.info_locked, + } + }) + except Exception as e: + logger.warning(f"WS广播分配事件失败: {e}") + + return conversation + # -------------------------------------------------------------------------- # 结单 # -------------------------------------------------------------------------- diff --git a/backend/app/services/todo_source_service.py b/backend/app/services/todo_source_service.py index ee36876..b593694 100644 --- a/backend/app/services/todo_source_service.py +++ b/backend/app/services/todo_source_service.py @@ -37,7 +37,7 @@ WECOM_GETAPPROVALINFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovali APPROVAL_DETAIL_CONCURRENCY = 10 # 查询审批数据的时间范围(最近 N 天) -APPROVAL_QUERY_DAYS = 7 +APPROVAL_QUERY_DAYS = 30 # 企微 getapprovaldata 单页查询上限 APPROVAL_PAGE_SIZE = 100 diff --git a/backend/app/services/triage_service.py b/backend/app/services/triage_service.py new file mode 100644 index 0000000..26fb05a --- /dev/null +++ b/backend/app/services/triage_service.py @@ -0,0 +1,990 @@ +# ============================================================================= +# 企微IT智能服务台 — 分诊业务逻辑服务 +# ============================================================================= +# 说明:分诊交互的核心业务逻辑,包括: +# 1. start_triage — 发起分诊(含5秒超时自动转人工) +# 2. submit_step — 提交步骤选择 +# 3. skip_step — 跳过步骤 +# 4. complete_triage — 分诊完成生成最终回复 +# 5. transfer_to_human — 转人工 +# 6. determine_urgency — 紧急度判断(关键词规则) +# 坐席端:list_pending / get_detail / route_session / get_history / export / exclude_options +# ============================================================================= + +import asyncio +import io +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +from openpyxl import Workbook +from sqlalchemy import func, select, and_, case +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.triage_session import TriageSession +from app.services.dify_triage_service import get_dify_triage_service + +logger = logging.getLogger(__name__) + +# ============================================================================= +# 紧急度判断关键词规则(决策 #3) +# ============================================================================= +# 扩展:同时用于"人工"按钮紧急直通判定 +URGENCY_HIGH_KEYWORDS: List[str] = [ + "紧急", "马上", "宕机", "无法工作", "崩溃", "死机", "蓝屏", + # 新增:紧急直通人工关键词(电脑无法启动、网络无法连接等) + "电脑无法启动", "网络无法连接", "多人不能上网", "无法上网", + "开不了机", "连不上网", "全部断网", +] +URGENCY_MEDIUM_KEYWORDS: List[str] = [ + "报错", "失败", "连不上", "打不开", "不能用", +] + +# ============================================================================= +# 信息锁定判定(决策 B2/B3) +# ============================================================================= +# 有效回答:不在以下集合中的回答。无效回答包括"人工""不知道"等。 +INVALID_ANSWERS = frozenset({ + "人工", "不知道", "不确定", "转人工", "跳过", "", +}) + +# 有效回答占比阈值:≥70% 判定为信息锁定 +INFO_LOCKED_THRESHOLD = 0.70 + +# ============================================================================= +# 关闭关键词识别(决策 G2:AI解决确认支持关键词识别) +# ============================================================================= +RESOLVE_KEYWORDS: List[str] = [ + "解决了", "谢谢", "没问题了", "可以了", "好了", + "弄好了", "搞定了", "不需要了", "撤销", "关闭", +] + + +class TriageService: + """分诊业务逻辑服务。 + + 管理 AI 分诊的完整生命周期,从发起分诊到最终路由。 + """ + + def __init__(self): + """初始化分诊服务。""" + self.dify_service = get_dify_triage_service() + + # ========================================================================== + # 紧急度判断(关键词规则) + # ========================================================================== + + @staticmethod + def determine_urgency(question: str, confidence: Optional[float] = None) -> str: + """根据关键词 + 置信度判断紧急度。 + + 规则: + 1. 含高级关键词(紧急/宕机/崩溃等)→ high + 2. 置信度 < 0.5 → high(低置信也视为紧急) + 3. 含中级关键词(报错/失败/连不上等)→ medium + 4. 其余 → low + + Args: + question: 员工问题文本 + confidence: AI 置信度(可选) + + Returns: + str: 紧急度(high/medium/low) + """ + if any(kw in question for kw in URGENCY_HIGH_KEYWORDS): + return "high" + if confidence is not None and confidence < 0.5: + return "high" + if any(kw in question for kw in URGENCY_MEDIUM_KEYWORDS): + return "medium" + return "low" + + # ========================================================================== + # H5 端方法 + # ========================================================================== + + async def start_triage( + self, + db: AsyncSession, + conversation_id: str, + question: str, + user_id: str, + user_name: str = "", + user_dept: str = "", + device_info: str = "", + ) -> Dict[str, Any]: + """发起分诊(含5秒超时自动转人工)。 + + 流程: + 1. 创建 triage_sessions 记录(status=triaging) + 2. 调用 Dify 分诊应用(5秒超时) + 3. 超时则自动转人工(status=timeout) + 4. 成功则更新分诊步骤和AI分析结果 + + Args: + db: 数据库会话 + conversation_id: 会话ID + question: 员工问题文本 + user_id: 员工ID + user_name: 员工姓名 + user_dept: 员工部门 + device_info: 设备信息 + + Returns: + Dict[str, Any]: 分诊结果或超时信息 + """ + # 1. 创建分诊会话记录 + session = TriageSession( + conversation_id=conversation_id, + user_id=user_id, + user_name=user_name, + user_dept=user_dept, + device_info=device_info, + request_title=question[:200] if question else "", + request_content=question, + source="wecom_h5", + status="triaging", + urgency="medium", + ) + db.add(session) + await db.commit() + await db.refresh(session) + + triage_id = session.id + logger.info("分诊会话已创建: triage_id=%s, user=%s", triage_id, user_id) + + # 2. 调用 Dify 分诊(5秒超时) + try: + result = await asyncio.wait_for( + self.dify_service.analyze(question, context=[], step_index=0), + timeout=float(settings.dify_triage_timeout), + ) + except asyncio.TimeoutError: + # 超时自动转人工 + logger.warning("分诊超时(>%s秒),自动转人工: triage_id=%s", + settings.dify_triage_timeout, triage_id) + await self._transfer_to_human_on_timeout(db, triage_id) + return { + "status": "timeout", + "message": "分诊超时,已自动转人工", + "triage_id": triage_id, + } + except RuntimeError as e: + # Dify 不可用,降级转人工 + logger.error("Dify 分诊不可用,降级转人工: triage_id=%s, error=%s", + triage_id, e) + await self._transfer_to_human_on_timeout(db, triage_id) + return { + "status": "timeout", + "message": "分诊服务暂时不可用,已自动转人工", + "triage_id": triage_id, + } + + # 3. 更新分诊会话 + confidence = result.get("confidence") + urgency = self.determine_urgency(question, confidence) + + # 覆盖 Dify 返回的紧急度(以关键词规则为准) + if result.get("urgency") and not any( + kw in question for kw in URGENCY_HIGH_KEYWORDS + URGENCY_MEDIUM_KEYWORDS + ): + urgency = result.get("urgency", "medium") + + session.triage_steps = result.get("triage_steps", []) + session.confidence = confidence + session.urgency = urgency + session.suggested_route = result.get("suggested_route") + session.problem_type = result.get("problem_type") + session.problem_category = result.get("problem_category") + session.matched_knowledge = result.get("matched_knowledge") + session.match_score = result.get("match_score") + session.context_tags = result.get("context_tags", []) + session.status = "triaging" + session.updated_at = datetime.now() + + await db.commit() + await db.refresh(session) + + logger.info( + "分诊分析完成: triage_id=%s, problem_type=%s, urgency=%s, steps=%d", + triage_id, + result.get("problem_type"), + urgency, + len(result.get("triage_steps", [])), + ) + + return { + "triage_id": triage_id, + "steps": result.get("triage_steps", []), + "total": len(result.get("triage_steps", [])), + "confidence": confidence, + "urgency": urgency, + "suggested_route": result.get("suggested_route"), + } + + async def submit_step( + self, + db: AsyncSession, + triage_id: str, + step_index: int, + selected_label: str, + ) -> Dict[str, Any]: + """提交步骤选择(含信息锁定判定)。 + + 记录用户选择的上下文,并根据选择动态调整后续步骤。 + 当所有步骤完成时,判定信息是否锁定: + - 有效回答占比 ≥ 70% → info_locked = true → WS推送 queue_segment_changed + - 有效回答占比 < 70% → info_locked = false(员工需继续回答诊断题补充) + + Args: + db: 数据库会话 + triage_id: 信息梳理会话ID + step_index: 当前步骤序号 + selected_label: 选择的选项标签 + + Returns: + Dict[str, Any]: 下一步骤数据、已收集上下文、信息锁定状态 + """ + session = await self._get_session(db, triage_id) + if not session: + return {"error": "信息梳理会话不存在"} + + # 记录已收集的上下文 + collected = list(session.collected_context or []) + if selected_label and selected_label not in collected: + collected.append(selected_label) + session.collected_context = collected + session.updated_at = datetime.now() + await db.commit() + + # 获取下一步骤(从预生成的步骤中取) + steps = session.triage_steps or [] + next_index = step_index + 1 + all_steps_done = next_index >= len(steps) + + if not all_steps_done: + next_step = steps[next_index] if next_index < len(steps) else None + else: + next_step = None + + # ================================================================== + # 信息锁定判定:所有步骤完成时触发 + # ================================================================== + info_locked = False + if all_steps_done: + info_locked = self._check_info_locked(collected) + if info_locked: + # 更新关联的 Conversation 表 + await self._update_conversation_info_locked( + db, session.conversation_id, locked=True + ) + logger.info( + "信息锁定成功: triage_id=%s, conversation_id=%s, " + "有效回答=%d/%d (%.0f%%)", + triage_id, session.conversation_id, + sum(1 for a in collected if a.strip() not in INVALID_ANSWERS), + len(collected), + (sum(1 for a in collected if a.strip() not in INVALID_ANSWERS) / max(len(collected), 1)) * 100 + ) + + # 标记信息梳理状态为完成 + session.status = "routed" + session.route_action = "info_locked" + session.updated_at = datetime.now() + await db.commit() + + # WS推送:队列段位变更 + await self._push_queue_segment_changed( + session.user_id, session.conversation_id, + "incomplete", "completed", + "信息梳理完成,已进入优先队列" + ) + + return { + "next_step": next_step, + "collected_context": collected, + "info_locked": info_locked, + "all_steps_done": all_steps_done, + } + + async def skip_step( + self, + db: AsyncSession, + triage_id: str, + step_index: int, + ) -> Dict[str, Any]: + """跳过步骤。 + + Args: + db: 数据库会话 + triage_id: 分诊会话ID + step_index: 要跳过的步骤序号 + + Returns: + Dict[str, Any]: 下一步骤数据 + """ + session = await self._get_session(db, triage_id) + if not session: + return {"error": "分诊会话不存在"} + + steps = session.triage_steps or [] + next_index = step_index + 1 + + session.updated_at = datetime.now() + await db.commit() + + if next_index < len(steps): + next_step = steps[next_index] + else: + next_step = None + + return {"next_step": next_step} + + async def complete_triage( + self, + db: AsyncSession, + triage_id: str, + context: List[str], + ) -> Dict[str, Any]: + """分诊完成,生成最终回复。 + + Args: + db: 数据库会话 + triage_id: 分诊会话ID + context: 已收集的上下文列表 + + Returns: + Dict[str, Any]: AI 回复和置信度 + """ + session = await self._get_session(db, triage_id) + if not session: + return {"error": "分诊会话不存在"} + + # 更新收集的上下文 + session.collected_context = context + session.status = "routed" + session.route_action = "ai_self" + session.updated_at = datetime.now() + + try: + # 调用 Dify 生成最终回复 + result = await self.dify_service.generate_reply( + session.request_content, context + ) + reply = result.get("reply", "根据您提供的信息,建议联系IT服务台获取进一步帮助。") + confidence = result.get("confidence", 0.0) + except RuntimeError as e: + logger.warning("Dify 生成回复失败,使用降级回复: %s", e) + reply = "根据您提供的信息,建议联系IT服务台获取进一步帮助。" + confidence = 0.0 + + await db.commit() + + return {"reply": reply, "confidence": confidence} + + async def transfer_to_human( + self, + db: AsyncSession, + triage_id: str, + context: List[str], + ) -> Dict[str, Any]: + """转人工。 + + Args: + db: 数据库会话 + triage_id: 分诊会话ID + context: 已收集的上下文列表 + + Returns: + Dict[str, Any]: 转人工结果 + """ + session = await self._get_session(db, triage_id) + if not session: + return {"error": "分诊会话不存在"} + + session.collected_context = context + session.status = "routed" + session.route_action = "human" + session.updated_at = datetime.now() + + await db.commit() + + return { + "conversation_id": session.conversation_id, + "status": "waiting_agent", + } + + # ========================================================================== + # 坐席端方法 + # ========================================================================== + + async def list_pending( + self, + db: AsyncSession, + urgency: Optional[str] = None, + problem_type: Optional[str] = None, + page: int = 1, + page_size: int = 20, + ) -> Dict[str, Any]: + """获取待分诊列表(按紧急度排序)。 + + 排序规则:high > medium > low,同紧急度按创建时间倒序。 + + Args: + db: 数据库会话 + urgency: 紧急度筛选 + problem_type: 问题类型筛选 + page: 页码 + page_size: 每页数量 + + Returns: + Dict[str, Any]: {total, items} + """ + # 构建查询条件 + conditions = [TriageSession.status.in_(["pending", "triaging"])] + if urgency: + conditions.append(TriageSession.urgency == urgency) + if problem_type: + conditions.append(TriageSession.problem_type == problem_type) + + # 紧急度排序:用 CASE 表达式 + urgency_order = case( + (TriageSession.urgency == "high", 0), + (TriageSession.urgency == "medium", 1), + (TriageSession.urgency == "low", 2), + else_=3, + ) + + stmt = ( + select(TriageSession) + .where(and_(*conditions)) + .order_by(urgency_order, TriageSession.created_at.desc()) + ) + + # 统计总数 + count_stmt = select(func.count()).select_from(TriageSession).where(and_(*conditions)) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页 + offset = (page - 1) * page_size + stmt = stmt.offset(offset).limit(page_size) + result = await db.execute(stmt) + items = result.scalars().all() + + return { + "total": total, + "items": [self._session_to_dict(s) for s in items], + } + + async def get_stats(self, db: AsyncSession) -> Dict[str, Any]: + """获取分诊看板统计概要。 + + Args: + db: 数据库会话 + + Returns: + Dict[str, Any]: 统计数据 + """ + now = datetime.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + # 待分诊总数 + pending_result = await db.execute( + select(func.count()).select_from(TriageSession).where( + TriageSession.status.in_(["pending", "triaging"]) + ) + ) + pending_total = pending_result.scalar() or 0 + + # 今日已分诊数 + today_result = await db.execute( + select(func.count()).select_from(TriageSession).where( + and_( + TriageSession.status == "routed", + TriageSession.operated_at >= today_start, + ) + ) + ) + today_triaged = today_result.scalar() or 0 + + # AI 自答数 + ai_self_result = await db.execute( + select(func.count()).select_from(TriageSession).where( + and_( + TriageSession.route_action == "ai_self", + TriageSession.operated_at >= today_start, + ) + ) + ) + ai_self_count = ai_self_result.scalar() or 0 + + # 转人工数 + human_result = await db.execute( + select(func.count()).select_from(TriageSession).where( + and_( + TriageSession.route_action == "human", + TriageSession.operated_at >= today_start, + ) + ) + ) + human_count = human_result.scalar() or 0 + + # 自动审批数 + auto_result = await db.execute( + select(func.count()).select_from(TriageSession).where( + and_( + TriageSession.route_action == "auto_approval", + TriageSession.operated_at >= today_start, + ) + ) + ) + auto_approval_count = auto_result.scalar() or 0 + + # 平均耗时(从创建到操作) + avg_result = await db.execute( + select( + func.avg( + func.extract("epoch", TriageSession.operated_at - TriageSession.created_at) + ) + ).where( + and_( + TriageSession.status == "routed", + TriageSession.operated_at.isnot(None), + TriageSession.operated_at >= today_start, + ) + ) + ) + avg_duration = avg_result.scalar() + avg_duration_sec = float(avg_duration) if avg_duration else 0.0 + + return { + "pending_total": pending_total, + "today_triaged": today_triaged, + "ai_self_count": ai_self_count, + "human_count": human_count, + "auto_approval_count": auto_approval_count, + "avg_duration_sec": round(avg_duration_sec, 1), + } + + async def get_detail(self, db: AsyncSession, triage_id: str) -> Optional[Dict[str, Any]]: + """获取分诊详情。 + + Args: + db: 数据库会话 + triage_id: 分诊会话ID + + Returns: + Optional[Dict[str, Any]]: 分诊详情字典,不存在返回 None + """ + session = await self._get_session(db, triage_id) + if not session: + return None + return self._session_to_detail_dict(session) + + async def route_session( + self, + db: AsyncSession, + triage_id: str, + route_action: str, + route_note: Optional[str], + operator_id: str, + ) -> Optional[Dict[str, Any]]: + """坐席路由操作(覆盖 AI 建议)。 + + Args: + db: 数据库会话 + triage_id: 分诊会话ID + route_action: 路由动作 + route_note: 路由备注 + operator_id: 操作坐席ID + + Returns: + Optional[Dict[str, Any]]: 更新后的分诊会话字典 + """ + session = await self._get_session(db, triage_id) + if not session: + return None + + session.route_action = route_action + session.route_note = route_note + session.operator_id = operator_id + session.operated_at = datetime.now() + session.status = "routed" if route_action != "skip" else "skipped" + session.updated_at = datetime.now() + + await db.commit() + await db.refresh(session) + + return self._session_to_dict(session) + + async def get_history( + self, + db: AsyncSession, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + route_action: Optional[str] = None, + page: int = 1, + page_size: int = 20, + ) -> Dict[str, Any]: + """获取已分诊历史列表。 + + Args: + db: 数据库会话 + date_from: 开始日期 + date_to: 结束日期 + route_action: 路由动作筛选 + page: 页码 + page_size: 每页数量 + + Returns: + Dict[str, Any]: {total, items} + """ + conditions = [TriageSession.status.in_(["routed", "skipped", "timeout"])] + + if date_from: + try: + dt_from = datetime.fromisoformat(date_from) + conditions.append(TriageSession.created_at >= dt_from) + except ValueError: + pass + if date_to: + try: + dt_to = datetime.fromisoformat(date_to) + conditions.append(TriageSession.created_at <= dt_to) + except ValueError: + pass + if route_action: + conditions.append(TriageSession.route_action == route_action) + + stmt = ( + select(TriageSession) + .where(and_(*conditions)) + .order_by(TriageSession.created_at.desc()) + ) + + count_stmt = select(func.count()).select_from(TriageSession).where(and_(*conditions)) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + offset = (page - 1) * page_size + stmt = stmt.offset(offset).limit(page_size) + result = await db.execute(stmt) + items = result.scalars().all() + + return { + "total": total, + "items": [self._session_to_dict(s) for s in items], + } + + async def export_sessions( + self, + db: AsyncSession, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + ) -> bytes: + """导出分诊记录为 xlsx。 + + 导出基础字段 + 分诊步骤详情。 + + Args: + db: 数据库会话 + date_from: 开始日期 + date_to: 结束日期 + + Returns: + bytes: xlsx 文件内容 + """ + conditions = [] + if date_from: + try: + dt_from = datetime.fromisoformat(date_from) + conditions.append(TriageSession.created_at >= dt_from) + except ValueError: + pass + if date_to: + try: + dt_to = datetime.fromisoformat(date_to) + conditions.append(TriageSession.created_at <= dt_to) + except ValueError: + pass + + stmt = select(TriageSession).order_by(TriageSession.created_at.desc()) + if conditions: + stmt = stmt.where(and_(*conditions)) + + result = await db.execute(stmt) + sessions = result.scalars().all() + + # 构建 Excel + wb = Workbook() + ws = wb.active + ws.title = "分诊记录" + + # 表头 + headers = [ + "分诊ID", "会话ID", "员工ID", "员工姓名", "部门", + "问题标题", "问题类型", "问题分类", "置信度", "紧急度", + "AI建议路由", "最终路由", "路由备注", "操作坐席", + "创建时间", "操作时间", "已收集上下文", "分诊步骤详情", + ] + ws.append(headers) + + # 数据行 + for s in sessions: + steps_detail = "" + if s.triage_steps: + for i, step in enumerate(s.triage_steps, 1): + q = step.get("question", "") + opts = " | ".join( + f"{o.get('label', '')}({o.get('probability', 0):.0%})" + for o in step.get("options", []) + ) + steps_detail += f"步骤{i}: {q} [{opts}]; " + + ws.append([ + s.id, + s.conversation_id, + s.user_id, + s.user_name or "", + s.user_dept or "", + s.request_title, + s.problem_type or "", + s.problem_category or "", + round(s.confidence, 2) if s.confidence else "", + s.urgency, + s.suggested_route or "", + s.route_action or "", + s.route_note or "", + s.operator_id or "", + s.created_at.strftime("%Y-%m-%d %H:%M:%S") if s.created_at else "", + s.operated_at.strftime("%Y-%m-%d %H:%M:%S") if s.operated_at else "", + " / ".join(s.collected_context or []), + steps_detail, + ]) + + # 调整列宽 + for col in ws.columns: + max_length = max(len(str(cell.value or "")) for cell in col) + ws.column_dimensions[col[0].column_letter].width = min(max_length + 2, 50) + + # 输出到内存 + output = io.BytesIO() + wb.save(output) + output.seek(0) + return output.getvalue() + + async def exclude_options( + self, + db: AsyncSession, + triage_id: str, + excluded_labels: List[str], + recommended_label: Optional[str], + ) -> Dict[str, Any]: + """坐席排除/推荐分诊选项(通过 WS 推送到 H5)。 + + Args: + db: 数据库会话 + triage_id: 分诊会话ID + excluded_labels: 要排除的选项标签列表 + recommended_label: 推荐的选项标签 + + Returns: + Dict[str, Any]: 排除结果 + """ + session = await self._get_session(db, triage_id) + if not session: + return {"error": "分诊会话不存在"} + + # 通过 WS 推送到 H5 端 + from app.services.ws_manager import manager as ws_manager + + ws_data = { + "type": "triage_exclude", + "data": { + "triage_id": triage_id, + "excluded_labels": excluded_labels, + "recommended_label": recommended_label, + }, + } + + await ws_manager.send_to_employee(session.user_id, ws_data) + logger.info( + "排除选项已推送: triage_id=%s, excluded=%s, recommended=%s", + triage_id, + excluded_labels, + recommended_label, + ) + + return {"excluded": True} + + # ========================================================================== + # 内部辅助方法 + # ========================================================================== + + @staticmethod + def _check_info_locked(collected_context: List[str]) -> bool: + """判定信息是否锁定(决策 B2/B3)。 + + 条件:有效回答占比 ≥ 70%。 + 有效回答 = 不在 INVALID_ANSWERS 集合中的回答。 + + Args: + collected_context: 已收集的上下文回答列表 + + Returns: + bool: True=已锁定,False=未锁定 + """ + if not collected_context: + return False + total = len(collected_context) + valid = sum(1 for ans in collected_context if ans.strip() not in INVALID_ANSWERS) + return (valid / total) >= INFO_LOCKED_THRESHOLD + + async def _update_conversation_info_locked( + self, db: AsyncSession, conversation_id: str, locked: bool + ) -> None: + """更新 Conversation 表的 info_locked 字段。 + + Args: + db: 数据库会话 + conversation_id: 会话ID + locked: 是否锁定 + """ + from app.models.conversation import Conversation + result = await db.execute( + select(Conversation).where(Conversation.id == conversation_id) + ) + conv = result.scalar_one_or_none() + if conv: + conv.info_locked = locked + conv.updated_at = datetime.now() + await db.commit() + logger.info("Conversation info_locked 更新: conv_id=%s, locked=%s", + conversation_id, locked) + + async def _push_queue_segment_changed( + self, + employee_id: str, + conversation_id: str, + old_segment: str, + new_segment: str, + message: str, + ) -> None: + """推送队列段位变更 WS事件(queue_segment_changed)。 + + 当 info_locked 变为 true 时,员工从"待梳理"段升级到"已梳理"段。 + + Args: + employee_id: 员工ID + conversation_id: 会话ID + old_segment: 原段位(incomplete) + new_segment: 新段位(completed) + message: 提示消息 + """ + try: + from app.services.ws_manager import manager as ws_manager + ws_data = { + "type": "queue_segment_changed", + "data": { + "conversation_id": conversation_id, + "old_segment": old_segment, + "new_segment": new_segment, + "message": message, + }, + } + await ws_manager.send_to_employee(employee_id, ws_data) + except Exception as e: + logger.warning("WS推送队列段位变更失败: %s", e) + + async def _get_session(self, db: AsyncSession, triage_id: str) -> Optional[TriageSession]: + """获取分诊会话记录。""" + result = await db.execute( + select(TriageSession).where(TriageSession.id == triage_id) + ) + return result.scalar_one_or_none() + + async def _transfer_to_human_on_timeout( + self, db: AsyncSession, triage_id: str + ) -> None: + """超时自动转人工。""" + session = await self._get_session(db, triage_id) + if session: + session.status = "timeout" + session.route_action = "human" + session.route_note = "分诊超时,自动转人工" + session.updated_at = datetime.now() + await db.commit() + + @staticmethod + def _session_to_dict(s: TriageSession) -> Dict[str, Any]: + """将会话对象转为列表项字典。""" + return { + "id": s.id, + "conversation_id": s.conversation_id, + "user_id": s.user_id, + "user_name": s.user_name, + "user_dept": s.user_dept, + "request_title": s.request_title, + "problem_type": s.problem_type, + "problem_category": s.problem_category, + "confidence": s.confidence, + "urgency": s.urgency, + "suggested_route": s.suggested_route, + "status": s.status, + "route_action": s.route_action, + "route_note": s.route_note, + "operator_id": s.operator_id, + "created_at": s.created_at.isoformat() if s.created_at else None, + "operated_at": s.operated_at.isoformat() if s.operated_at else None, + } + + @staticmethod + def _session_to_detail_dict(s: TriageSession) -> Dict[str, Any]: + """将会话对象转为详情字典。""" + return { + "id": s.id, + "conversation_id": s.conversation_id, + "user_id": s.user_id, + "user_name": s.user_name, + "user_dept": s.user_dept, + "user_level": s.user_level, + "device_info": s.device_info, + "request_title": s.request_title, + "request_content": s.request_content, + "source": s.source, + "problem_type": s.problem_type, + "problem_category": s.problem_category, + "confidence": s.confidence, + "urgency": s.urgency, + "suggested_route": s.suggested_route, + "matched_knowledge": s.matched_knowledge, + "match_score": s.match_score, + "context_tags": s.context_tags or [], + "triage_steps": s.triage_steps or [], + "collected_context": s.collected_context or [], + "status": s.status, + "route_action": s.route_action, + "route_note": s.route_note, + "operator_id": s.operator_id, + "created_at": s.created_at.isoformat() if s.created_at else None, + "updated_at": s.updated_at.isoformat() if s.updated_at else None, + "operated_at": s.operated_at.isoformat() if s.operated_at else None, + } + + +# 单例 +_triage_service: Optional[TriageService] = None + + +def get_triage_service() -> TriageService: + """获取 TriageService 单例。 + + Returns: + TriageService: 单例实例 + """ + global _triage_service + if _triage_service is None: + _triage_service = TriageService() + return _triage_service diff --git a/backend/app/services/wecom_service.py b/backend/app/services/wecom_service.py index ae326ca..09bc07a 100644 --- a/backend/app/services/wecom_service.py +++ b/backend/app/services/wecom_service.py @@ -1171,6 +1171,78 @@ class WecomService: logger.error(f"获取 jsapi_ticket 网络错误: {e}") raise Exception(f"企微API网络错误: {e}") from e + async def get_agent_config_ticket(self) -> str: + """获取企微 agent_config_ticket。 + + 对应企微API: + GET https://qyapi.weixin.qq.com/cgi-bin/ticket/get?type=agent_config&access_token=TOKEN + + agent_config_ticket 用于 wx.agentConfig() 的签名计算。 + 与 jsapi_ticket 是不同的票据,不能混用。 + 有效期 7200 秒,缓存到 Redis(提前 300 秒刷新)。 + + Returns: + str: agent_config_ticket 字符串 + + Raises: + Exception: 获取失败 + """ + cache_key = "wecom:agent_config_ticket" + + # 1. Redis 缓存 + if self.redis: + try: + cached = await self.redis.get(cache_key) + if cached: + if isinstance(cached, bytes): + cached = cached.decode("utf-8") + logger.debug("从缓存获取 agent_config_ticket") + return cached + except Exception as e: + logger.warning(f"Redis 读取 agent_config_ticket 失败(降级): {e}") + + # 2. 调用企微 API + access_token = await self.get_access_token() + url = ( + f"https://qyapi.weixin.qq.com/cgi-bin/ticket/get" + f"?type=agent_config&access_token={access_token}" + ) + + try: + response = await self.client.get(url) + result = response.json() + + if result.get("errcode", 0) != 0: + logger.error( + f"获取 agent_config_ticket 失败: " + f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}" + ) + raise Exception( + f"获取 agent_config_ticket 失败: {result.get('errmsg')}" + ) + + ticket = result.get("ticket", "") + expires_in = result.get("expires_in", 7200) + + # 3. 缓存到 Redis(TTL = expires_in - 300s) + cache_ttl = max(expires_in - 300, 60) + if self.redis: + try: + await self.redis.setex(cache_key, cache_ttl, ticket) + except Exception as e: + logger.warning( + f"Redis 写入 agent_config_ticket 失败(降级): {e}" + ) + + logger.info( + f"agent_config_ticket 获取成功,缓存 TTL={cache_ttl}秒" + ) + return ticket + + except httpx.HTTPError as e: + logger.error(f"获取 agent_config_ticket 网络错误: {e}") + raise Exception(f"企微API网络错误: {e}") from e + @staticmethod def generate_jsapi_signature( ticket: str, nonce_str: str, timestamp: int, url: str @@ -1185,9 +1257,11 @@ class WecomService: - url 不含 # 及其后面部分 - url 不含 ? - url 是前端调用 wx.config 的页面 URL + - 此方法同时用于 jsapi_ticket 和 agent_config_ticket 的签名计算 + (签名算法相同,只是 ticket 不同) Args: - ticket: jsapi_ticket + ticket: jsapi_ticket 或 agent_config_ticket nonce_str: 随机字符串(前端生成,16位) timestamp: 当前时间戳(秒) url: 当前页面 URL(不含 # 后面) diff --git a/backend/app/tasks/h5_ai_task.py b/backend/app/tasks/h5_ai_task.py index 0c9f2e8..296eefb 100644 --- a/backend/app/tasks/h5_ai_task.py +++ b/backend/app/tasks/h5_ai_task.py @@ -14,8 +14,13 @@ # (请求返回后该 session 会被关闭)。 # ============================================================================= +import asyncio import logging -from datetime import datetime +import os +from datetime import datetime, timedelta +from pathlib import Path + +from sqlalchemy import select from app.api.byod import _byod_keyword_prefilter from app.database import _get_session_factory @@ -30,11 +35,184 @@ from app.services.routing_service import ( record_routing_event, _keyword_fallback_category, ) +from app.services.vision_service import VisionService from app.services.ws_manager import manager as ws_manager logger = logging.getLogger(__name__) +# ============================================================================= +# Phase 4A: VisionService 接入 — 图片消息视觉理解 +# ============================================================================= + +# 图片文件本地存储根目录(与 upload.py 中 UPLOAD_DIR 一致) +_UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "./uploads")) + +# 视觉理解置信度阈值:低于此值不注入描述(避免错误描述误导 AI) +_VISION_CONFIDENCE_THRESHOLD = 0.6 + + +def _media_url_to_local_path(media_url: str) -> Path: + """将媒体 URL 路径转换为本地文件系统路径。 + + 做什么:把 "/api/media/2026/07/13/abc.png" 转换为 + "./uploads/2026/07/13/abc.png" + 为什么:VisionService 需要读取原始图片字节流, + 而媒体 URL 是 HTTP 访问路径,不是文件系统路径。 + + Args: + media_url: 媒体文件 URL(如 /api/media/2026/07/13/abc.png) + + Returns: + Path: 本地文件路径对象 + """ + # 去掉 URL 前缀 /api/media/,拼接到 UPLOAD_DIR + # 例: "/api/media/2026/07/13/abc.png" → "2026/07/13/abc.png" + relative = media_url.replace("/api/media/", "", 1) + return _UPLOAD_DIR / relative + + +async def _fetch_recent_employee_text( + db, conversation_id: str, employee_id: str, within_seconds: int = 5 +) -> str: + """获取最近 N 秒内员工的文字消息(Phase 4B 消息融合)。 + + 做什么:查询同一会话中,当前图片消息之前 within_seconds 秒内, + 员工发送的文本消息内容。 + 为什么:用户经常先打字描述问题再发截图,或先发截图再补充文字。 + 将文字与图片视觉描述融合后一次性传给 Dify, + 避免 AI 分别处理两条消息导致上下文割裂。 + + Args: + db: 异步 DB session + conversation_id: 会话 ID + employee_id: 员工企微 UserID + within_seconds: 时间窗口(秒),默认 5 秒 + + Returns: + str: 最近的员工文字消息内容(多条用换行拼接),无则返回空字符串 + """ + cutoff = datetime.now() - timedelta(seconds=within_seconds) + stmt = ( + select(Message) + .where( + Message.conversation_id == conversation_id, + Message.sender_type == "employee", + Message.sender_id == employee_id, + Message.msg_type == "text", + Message.created_at >= cutoff, + ) + .order_by(Message.created_at.desc()) + .limit(3) # 最多取 3 条,避免内容过长 + ) + result = await db.execute(stmt) + messages = result.scalars().all() + + if not messages: + return "" + + # 按时间正序拼接(先发的在前) + texts = [m.content for m in reversed(messages) if m.content] + return "\n".join(texts) + + +async def _enrich_image_content( + db, + media_url: str, + original_content: str, + conversation_id: str, + employee_id: str, +) -> str: + """用 VisionService 分析图片,生成增强后的消息内容。 + + 做什么: + 1. 从本地文件系统读取图片 + 2. 调用 VisionService.analyze_screenshot() 获取视觉描述 + 3. 查询最近 5 秒内的员工文字消息(消息融合) + 4. 拼接视觉描述 + 用户文字 → 传给 Dify + + 为什么:Dify 文本模型无法直接"看"图片,需要先将图片转为 + 文字描述,再与用户输入融合后传给 Dify 推理。 + + 降级策略: + - 图片文件不存在 → 返回原始 content + - VisionService 调用失败 → 返回 "我收到了您的截图,但暂时无法识别内容" + - 置信度 < 0.6 → 不注入视觉描述,仅使用用户文字 + + Args: + db: 异步 DB session + media_url: 图片 URL(如 /api/media/2026/07/13/abc.png) + original_content: 原始消息内容(如 "[图片] 截图") + conversation_id: 会话 ID + employee_id: 员工企微 UserID + + Returns: + str: 增强后的消息内容(视觉描述 + 用户文字) + """ + # 1. 读取本地图片文件 + local_path = _media_url_to_local_path(media_url) + if not local_path.exists(): + logger.warning(f"图片文件不存在: {local_path} (media_url={media_url})") + return original_content + + try: + image_bytes = local_path.read_bytes() + except Exception as e: + logger.error(f"读取图片文件失败: {local_path} - {e}") + return original_content + + # 2. 调用 VisionService 分析截图 + vision_service = VisionService() + try: + result = await vision_service.analyze_screenshot( + image_bytes, conversation_id + ) + description = result.get("description", "") + confidence = result.get("confidence", 0.0) + + logger.info( + f"VisionService 分析完成: conversation={conversation_id}, " + f"confidence={confidence:.2f}, desc_len={len(description)}" + ) + + # 3. 注入视觉描述到会话上下文(供后续多轮对话使用) + if description and confidence >= _VISION_CONFIDENCE_THRESHOLD: + await vision_service.inject_to_conversation_context( + description, conversation_id + ) + except Exception as e: + logger.error(f"VisionService 调用异常: {e}") + description = "" + confidence = 0.0 + finally: + await vision_service.close() + + # 4. 消息融合:查询最近 5 秒内员工的文字消息 + recent_text = await _fetch_recent_employee_text( + db, conversation_id, employee_id, within_seconds=5 + ) + + # 5. 拼接增强内容 + # 格式:[视觉描述] + [用户最近文字] + [原始消息内容] + parts = [] + + if description and confidence >= _VISION_CONFIDENCE_THRESHOLD: + parts.append(f"[用户发送了截图,视觉理解结果] {description}") + + if recent_text: + parts.append(f"[用户最近的文字描述] {recent_text}") + + # 原始内容如果不是纯占位符(如"[图片] 截图"),也加入 + if original_content and not original_content.startswith("[图片]"): + parts.append(original_content) + + if not parts: + # 降级:视觉分析失败且无文字补充 + return "我收到了您的截图,但暂时无法识别内容,请描述一下您遇到的问题。" + + return "\n".join(parts) + + async def _persist_and_push( db, conversation: Conversation, @@ -124,6 +302,174 @@ async def _persist_and_push( logger.warning(f"WS 广播 AI 回复给坐席失败(消息已存储): {ws_err}") +async def _persist_and_push_structured( + db, + conversation: Conversation, + employee_id: str, + result: dict, +): + """持久化结构化 AI 回复并推送给员工端 + 广播坐席端(v2.0 双 WS 通道)。 + + 改造后的核心变化(2026-07-13): + - Dify 返回 JSON {text, action, options},后端解析后同时发两条 WS: + ① ai_reply → 聊天气泡(text + options) + ② dynamic_recommend → 侧边栏推荐(action 卡片) + - 两条消息同一时刻发出,零时间差到达 + - 文字明确引用侧边栏内容(如"右侧已为您准备好入口"),语义强关联 + + 命中判断规则: + - 结构化回复且有 action 或 options → 视为命中(AI 在主动引导) + - 纯文本回复 → 走原有 _check_knowledge_hit 判断 + + Args: + db: 异步 DB session + conversation: 当前会话对象 + employee_id: 员工企微 UserID + result: get_structured_reply() 返回的结构化结果 + """ + text = result.get("text", "") + action = result.get("action") + options = result.get("options") + hit = result.get("hit", False) + is_structured = result.get("is_structured", False) + dify_conv_id = result.get("conversation_id") + # Phase 6A: 提取诊断阶段 + diagnosis_stage = result.get("diagnosis_stage") + + # 结构化回复且有 action 或 options → 视为命中(AI 在主动引导/推荐) + if is_structured and (action or options): + hit = True + + # Phase 6A: 基于 diagnosis_stage 调整会话状态 + # escalating → AI 建议转人工 + # resolved → AI 认为问题已解决 + if diagnosis_stage == "escalating": + hit = False # 不计为有效回复,触发转人工 + elif diagnosis_stage == "resolved": + hit = True # 计为有效回复 + + should_count = hit + should_transfer = not hit + + # 确定消息类型 + if is_structured and (options or action): + msg_type = "ai_structured" + else: + msg_type = "text" + + # 构建 extra_data(存储 options 和 action 供前端渲染) + extra_data = {} + if options: + extra_data["options"] = options + if action: + extra_data["action"] = action + + # 1. 存 AI 消息 + ai_message = Message( + conversation_id=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_read=True, + ) + db.add(ai_message) + await db.flush() + + # 2. 更新会话状态 + if dify_conv_id: + conversation.dify_conversation_id = dify_conv_id + if should_count: + conversation.ai_substantive_reply_count += 1 + if should_transfer: + conversation.status = "queued" + # Phase 6A: 将 diagnosis_stage 存入 tags(无需迁移,利用现有 JSON 字段) + if diagnosis_stage: + tags = conversation.tags or {} + tags["diagnosis_stage"] = diagnosis_stage + tags["diagnosis_updated_at"] = datetime.now().isoformat() + conversation.tags = tags + conversation.updated_at = datetime.now() + db.add(conversation) + await db.flush() + 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, + }, + }) + + # 4. 推 dynamic_recommend 给员工端侧边栏(仅当 action 非空时) + # 与 ai_reply 同一时刻发出 → 零时间差到达 + if action: + 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"), + "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']}" + ) + + # 5. 广播坐席端(new_message + conversation_updated) + try: + await ws_manager.broadcast({ + "type": "new_message", + "data": { + "conversation_id": str(conversation.id), + "message_id": str(ai_message.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, + }, + }) + await ws_manager.broadcast({ + "type": "conversation_updated", + "data": { + "conversation_id": str(conversation.id), + "status": conversation.status, + "assigned_agent_id": ( + str(conversation.assigned_agent_id) + if conversation.assigned_agent_id else None + ), + }, + }) + except Exception as ws_err: + logger.warning(f"WS 广播结构化 AI 回复给坐席失败(消息已存储): {ws_err}") + + async def _handle_byod_query(db, conversation, employee_id, content): """处理 BYOD 自备电脑补贴查询。 @@ -370,13 +716,38 @@ async def process_h5_ai_reply( employee_id: str, content: str, dify_conversation_id=None, + msg_type: str = "text", + media_url: str = None, ): """H5 发送消息后的 AI 回复处理(asyncio.create_task 入口)。 + v2.0 改造(2026-07-13): + - AI 回复从流式 SSE 改为 blocking + JSON 结构化输出 + - Dify 返回 {text, action, options} JSON → 后端解析 → 双 WS 推送 + - 聊天气泡收到 ai_reply(text + options),侧边栏收到 dynamic_recommend(action) + - 新增 ai_thinking 指示器,用户发送后立即看到"正在思考..." + + v2.1 改造(2026-07-13 Phase 4): + - 新增图片消息处理分支(msg_type=image) + - 图片 → VisionService.analyze_screenshot() → 视觉描述 → 融合到用户文字 + - 消息融合:查询最近 5 秒内员工的文字消息,与图片描述合并后传给 Dify + - 降级:VisionService 失败/低置信度 → 使用原始文字或提示用户描述问题 + 流程: - - 本地快判断(打招呼 / 呼叫人工)→ 同步结果,整段推送(不调 Dify) - - 否则流式调 Dify,逐 chunk 推 ai_reply_chunk,流结束推 ai_reply 终态 - - 任意异常 → 推 ai_reply_failed,不阻塞用户 + 1. BYOD 关键词拦截 → byod_card 卡片 + 2. 路由关键词拦截 → 名片推荐 + 3. 本地快判断(打招呼/呼叫人工)→ 同步引导 + 4. ★ 图片消息处理(Phase 4A)→ VisionService 分析 → 内容增强 + 5. ★ 结构化 AI 回复(blocking + JSON 解析 + 双 WS 推送) + 6. 任意异常 → 推 ai_reply_failed + + Args: + conversation_id: 会话 ID + employee_id: 员工企微 UserID + content: 消息文本内容 + dify_conversation_id: Dify 会话 ID(用于多轮上下文) + msg_type: 消息类型(text/image/file),默认 text + media_url: 媒体文件 URL(图片消息时使用) """ ai_handler = get_shared_ai_handler() factory = _get_session_factory() @@ -387,31 +758,20 @@ async def process_h5_ai_reply( logger.warning(f"后台 AI 任务:会话不存在 {conversation_id}") return - is_guidance = False - should_count = False - should_transfer = False - new_dify_conv_id = dify_conversation_id - full_parts: list = [] - - # === BYOD 关键词拦截 === - # 在打招呼/呼叫人工判断之前,先检查是否为 BYOD(自备电脑补贴)意图。 - # 命中关键词 → 执行 BYOD 资格检查并推送 byod_card 卡片,不走正常 AI 流程。 - if _byod_keyword_prefilter(content): + # === BYOD 关键词拦截(仅文本消息)=== + # 图片消息的 content 是占位符(如 "[图片] 截图"),跳过关键词拦截 + if msg_type == "text" and _byod_keyword_prefilter(content): await _handle_byod_query(db, conversation, employee_id, content) - return # BYOD 处理完毕,直接返回 + return - # === 业务路由检测(新增)=== - # 在 BYOD 检测之后、打招呼/呼叫人工检测之前,检查是否为非IT业务路由。 - # 命中路由关键词 → 调用 Dify 统一意图识别 → non_it_routing && confidence≥0.7 - # → 发送名片三段式消息(路由文本 + contact_card + 系统提示) - # 置信度不足或非路由意图 → 继续往下走正常 AI 流程 - if routing_keyword_prefilter(content): + # === 业务路由检测(仅文本消息)=== + if msg_type == "text" and routing_keyword_prefilter(content): routed = await _handle_routing(db, conversation, employee_id, content) if routed: - return # 路由名片已发送,直接返回 + return - # 本地快判断(不打 Dify):打招呼 / 呼叫人工 → 同步路径 - if ai_handler.is_greeting(content) or ai_handler.is_call_human(content): + # === 本地快判断:打招呼 / 呼叫人工(仅文本消息)=== + if msg_type == "text" and (ai_handler.is_greeting(content) or ai_handler.is_call_human(content)): result = await ai_handler.handle_message( content=content, dify_conversation_id=dify_conversation_id, @@ -424,40 +784,110 @@ async def process_h5_ai_reply( ) return - # 流式调 Dify(get_reply_stream 内部已处理真 SSE / 非流式 fallback) - # 注意:首参是 message(用户文本),不是 content - async for chunk in ai_handler.ai_service.get_reply_stream( - message=content, - conversation_id=dify_conversation_id, - user_id=employee_id, - ): - delta = chunk.get("delta", "") - if delta: - full_parts.append(delta) - await ws_manager.broadcast_to_employees([employee_id], { - "type": "ai_reply_chunk", - "data": { - "conversation_id": conversation_id, - "chunk": delta, - }, - }) - if chunk.get("finished"): - new_dify_conv_id = chunk.get("conversation_id") or dify_conversation_id - hit = chunk.get("hit") - # 命中 → 计数;未命中 → 转人工 - should_count = bool(hit) - should_transfer = not bool(hit) + # === ★ v2.1 图片消息处理(Phase 4A/4B)=== + # 做什么:检测到图片消息 → 调用 VisionService 分析截图 → + # 将视觉描述与用户文字融合 → 传给 Dify 推理 + # 为什么:Dify 文本模型无法"看"图片,需要先将图片转为文字描述 + # 降级:VisionService 失败 → 使用原始 content 继续流程 + enriched_content = content # 默认使用原始内容 + if msg_type == "image" and media_url: + logger.info( + f"图片消息检测: conversation={conversation_id}, " + f"media_url={media_url}" + ) + try: + enriched_content = await _enrich_image_content( + db=db, + media_url=media_url, + original_content=content, + conversation_id=conversation_id, + employee_id=employee_id, + ) + logger.info( + f"图片内容增强完成: original_len={len(content)}, " + f"enriched_len={len(enriched_content)}" + ) + except Exception as vision_err: + logger.error( + f"VisionService 处理失败,降级为纯文本: {vision_err}" + ) + # 降级:使用原始 content,AI 会收到 "[图片] 截图" 这样的占位符 + # Dify 会回复"我收到了您的截图,请描述一下问题" - content_ai = "".join(full_parts) - if not content_ai: - # 流式无内容(极端情况),给降级提示,不转人工 - content_ai = "⚠️ AI 暂时没有返回内容,请输入「IT」转人工。" - should_count = False - should_transfer = False - await _persist_and_push( - db, conversation, employee_id, content_ai, - is_guidance, should_count, should_transfer, new_dify_conv_id, + # === ★ v2.0 结构化 AI 回复(替代流式)=== + # 1. 立即推送 "正在思考..." 指示器 + # 同时推给员工(气泡动画)和坐席(状态指示) + await ws_manager.broadcast_to_employees([employee_id], { + "type": "ai_thinking", + "data": { + "conversation_id": conversation_id, + }, + }) + # 坐席端也通知:AI 正在处理此会话的消息 + try: + await ws_manager.broadcast({ + "type": "ai_thinking", + "data": { + "conversation_id": conversation_id, + "employee_id": employee_id, + }, + }) + except Exception: + pass # 坐席端通知失败不影响主流程 + + # 2. 启动延迟 "仍在思考" 后台任务(15 秒后触发) + # 如果 Dify 在 15 秒内返回,此任务会被取消 + async def _push_still_thinking(): + """15 秒后推送 "仍在思考" 提示,缓解用户等待焦虑。""" + await asyncio.sleep(15) + await ws_manager.broadcast_to_employees([employee_id], { + "type": "ai_thinking", + "data": { + "conversation_id": conversation_id, + "status": "still_thinking", + }, + }) + + thinking_task = asyncio.create_task(_push_still_thinking()) + + # 3. 调用 Dify(blocking 模式 + JSON 解析 + 30 秒硬超时) + # get_structured_reply 内部处理 HTTP 错误和 JSON 解析失败 + # asyncio.wait_for 处理 30 秒硬超时 → 建议转人工 + # 注意:图片消息使用 enriched_content(视觉描述+用户文字融合) + try: + result = await asyncio.wait_for( + ai_handler.ai_service.get_structured_reply( + message=enriched_content, + conversation_id=dify_conversation_id, + user_id=employee_id, + ), + timeout=30, + ) + except asyncio.TimeoutError: + # 30 秒硬超时 → 建议转人工 + thinking_task.cancel() + logger.warning(f"Dify 30 秒超时: conversation={conversation_id}") + await ws_manager.broadcast_to_employees([employee_id], { + "type": "ai_reply_failed", + "data": { + "conversation_id": conversation_id, + "message": "AI 响应时间较长,建议转人工坐席处理。", + }, + }) + return + + # 4. 取消 "仍在思考" 任务(Dify 已返回) + thinking_task.cancel() + try: + await thinking_task # 等待 task 真正取消,避免 warning + except asyncio.CancelledError: + pass + + # 5. 持久化 + 双 WS 推送(ai_reply + dynamic_recommend) + await _persist_and_push_structured( + db, conversation, employee_id, result, ) + except Exception as e: logger.error(f"后台 AI 任务异常: {e}", exc_info=True) try: @@ -465,7 +895,7 @@ async def process_h5_ai_reply( "type": "ai_reply_failed", "data": { "conversation_id": conversation_id, - "message": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。", + "message": "AI 服务异常,请转人工坐席或稍后重试。", }, }) except Exception: diff --git a/backend/app/tasks/quiz_generation_task.py b/backend/app/tasks/quiz_generation_task.py new file mode 100644 index 0000000..d210a30 --- /dev/null +++ b/backend/app/tasks/quiz_generation_task.py @@ -0,0 +1,174 @@ +# ============================================================================= +# 企微IT智能服务台 — 每日测验题目自动生成定时任务 +# ============================================================================= +# 说明:每天凌晨 3:00 自动执行: +# 1. 按星期轮转生成 1 个类别的 5 道知识题(7 天一轮) +# 2. 分析近 7 天已解决工单 → 生成 3 道诊断题 +# 3. 停用被 >80% 活跃员工答过的陈旧题目 +# +# 调度:CronTrigger(hour=3, minute=0) +# 降级:Dify 不可用时记录错误,不影响其他步骤 +# ============================================================================= + +import datetime +import logging + +logger = logging.getLogger(__name__) + +# 7 个题目类别(按星期轮转:周一=network, 周二=vpn, ...) +QUIZ_CATEGORIES = ["network", "vpn", "email", "system", "printer", "security", "office"] + +# 每日知识题生成数量 +KNOWLEDGE_QUESTIONS_PER_DAY = 5 + +# 诊断题生成数量 +DIAGNOSTIC_QUESTIONS_PER_BATCH = 3 + +# 默认诊断题问题类别(当近期无工单数据时使用) +DEFAULT_DIAGNOSTIC_CATEGORIES = ["network_connect", "vpn_auth_fail", "printer_offline"] + + +async def run_daily_quiz_generation(): + """每日测验题目生成任务。 + + 执行流程: + 1. 按星期轮转生成 1 个类别的 5 道知识题 + 2. 分析近 7 天已解决工单 → 取 top 1 问题类别生成 3 道诊断题 + 3. 停用被 >80% 活跃员工答过的陈旧题 + 4. 记录汇总日志 + + 调度:每日 03:00(CronTrigger hour=3, minute=0) + """ + from app.database import _get_session_factory + from app.services.quiz_generation_service import get_quiz_generation_service + + logger.info("===== 开始每日测验题目生成 =====") + + factory = _get_session_factory() + service = get_quiz_generation_service() + + total_generated = 0 + total_errors = 0 + + async with factory() as db: + try: + # ============================================================ + # 1. 知识题生成(按星期轮转 1 个类别) + # ============================================================ + today = datetime.date.today() + weekday = today.weekday() # 0=Monday, 6=Sunday + category = QUIZ_CATEGORIES[weekday] + + logger.info(f"今日轮转类别: {category} (weekday={weekday})") + + try: + result = await service.generate_knowledge_questions_batch( + db=db, + category=category, + count=KNOWLEDGE_QUESTIONS_PER_DAY, + is_active=False, # 定时生成的题目需管理员审批 + ) + total_generated += result["success_count"] + total_errors += result["failed_count"] + logger.info( + f"知识题生成 [{category}]: " + f"成功 {result['success_count']}, 失败 {result['failed_count']}" + ) + if result["errors"]: + logger.warning(f"知识题错误详情: {result['errors'][:3]}") + except Exception as e: + logger.error(f"知识题生成 [{category}] 异常: {e}") + total_errors += KNOWLEDGE_QUESTIONS_PER_DAY + + # ============================================================ + # 2. 诊断题生成(基于近期工单模式) + # ============================================================ + try: + # 获取近期工单摘要 + ticket_summaries = await service._get_recent_ticket_summaries( + db, days=7, limit=20 + ) + + # 提取 top 1 问题类别 + problem_category = _extract_top_problem_category(ticket_summaries) + + logger.info(f"诊断题问题类别: {problem_category}") + + result = await service.generate_diagnostic_questions_batch( + db=db, + problem_category=problem_category, + count=DIAGNOSTIC_QUESTIONS_PER_BATCH, + ticket_summaries=[t["summary"] for t in ticket_summaries if t["summary"]], + is_active=False, + ) + total_generated += result["success_count"] + total_errors += result["failed_count"] + logger.info( + f"诊断题生成 [{problem_category}]: " + f"成功 {result['success_count']}, 失败 {result['failed_count']}" + ) + except Exception as e: + logger.error(f"诊断题生成异常: {e}") + total_errors += DIAGNOSTIC_QUESTIONS_PER_BATCH + + # ============================================================ + # 3. 停用陈旧题目 + # ============================================================ + try: + stale_result = await service.deactivate_stale_questions( + db=db, threshold=0.8 + ) + if stale_result["deactivated_count"] > 0: + logger.info( + f"陈旧题目停用: {stale_result['deactivated_count']} 道 " + f"(活跃员工 {stale_result['total_active_employees']} 人)" + ) + except Exception as e: + logger.error(f"陈旧题目停用异常: {e}") + + # 统一提交 + await db.commit() + + except Exception as e: + await db.rollback() + logger.error(f"每日题目生成任务异常: {e}", exc_info=True) + + logger.info( + f"===== 每日题目生成完成: 新增 {total_generated} 道, " + f"失败 {total_errors} 道 =====" + ) + + +def _extract_top_problem_category( + ticket_summaries: list, +) -> str: + """从工单摘要中提取最高频的问题类别。 + + 基于 category_hint 字段统计频率。 + 降级:如果无数据,返回默认类别。 + + Args: + ticket_summaries: _get_recent_ticket_summaries() 返回的列表 + + Returns: + str: 问题类别标识(如 "vpn_disconnect") + """ + if not ticket_summaries: + # 无工单数据时返回默认 + return DEFAULT_DIAGNOSTIC_CATEGORIES[0] + + # 统计 category_hint 频率 + hint_counts: dict[str, int] = {} + for ticket in ticket_summaries: + hint = ticket.get("category_hint", "") + if hint: + hint_counts[hint] = hint_counts.get(hint, 0) + 1 + + if not hint_counts: + return DEFAULT_DIAGNOSTIC_CATEGORIES[0] + + # 取最高频的类别 + top_category = max(hint_counts, key=hint_counts.get) + + # 拼接为 problem_category 格式(如 "vpn_disconnect") + return f"{top_category}_issue" diff --git a/backend/app/tasks/reminder_task.py b/backend/app/tasks/reminder_task.py index 16f0bd4..255c509 100644 --- a/backend/app/tasks/reminder_task.py +++ b/backend/app/tasks/reminder_task.py @@ -3,9 +3,11 @@ # ============================================================================= # 说明:定时检查超时未回复的会话,发送企微提醒消息 # 运行频率:每 30 秒执行一次 -# 超时逻辑: +# 超时逻辑(改造后,决策 G4): # 1. 坐席回复后 3 分钟员工未回复 -> 发送企微提醒(只发 1 次) # 2. 坐席回复后 10 分钟员工未回复 -> 标记会话为 pending_close +# 3. pending_close 后 5 分钟员工未响应 -> 自动 resolved(新增) +# 4. ai_handling 30 分钟无互动 -> 发送提醒 -> 再 10 分钟 -> 自动 resolved(新增) # ============================================================================= import logging @@ -21,28 +23,37 @@ logger = logging.getLogger(__name__) # 超时配置(分钟) REMINDER_TIMEOUT_MINUTES = 3 # 未回复超时时间 CLOSE_TIMEOUT_MINUTES = 10 # 自动待关闭时间 +PENDING_CLOSE_RESOLVE_MINUTES = 5 # pending_close → resolved 超时 + +# AI处理阶段超时配置 +AI_HANDLING_TIMEOUT_MINUTES = 30 # AI处理超时(无互动) +AI_HANDLING_REMINDER_TO_CLOSE_MINUTES = 10 # 提醒后仍未响应的关闭时间 async def check_unreplied_sessions(): - """检查超时未回复的会话,发送提醒并标记待关闭。 + """检查超时未回复的会话,发送提醒并标记待关闭/自动关闭。 此函数由 APScheduler 定时调用(每 30 秒)。 执行流程: - 1. 查找需要发送提醒的会话(坐席回复超过3分钟,员工未回复且未发送过提醒) - 2. 发送企微提醒消息 - 3. 标记已发送提醒 - 4. 查找需要标记待关闭的会话(坐席回复超过10分钟) - 5. 更新会话状态为 pending_close + 1. serving 状态:发送提醒 + 标记 pending_close(原有逻辑) + 2. pending_close 状态:超时自动 resolved(新增) + 3. ai_handling 状态:超时提醒 + 自动 resolved(新增) """ - # 导入数据库 session 工厂 from app.database import _get_session_factory + from app.services.closing_service import ClosingService async_session_factory = _get_session_factory() async with async_session_factory() as db: try: - # 1. 查找需要发送提醒的会话 - # 条件:active 状态 + 有坐席回复 + 超过3分钟未回复 + 未发送过提醒 + closing_service = ClosingService(db) + + # ================================================================== + # 1. serving 状态:发送提醒 + 标记 pending_close(原有逻辑) + # ================================================================== + + # 1a. 查找需要发送提醒的会话 + # 条件:serving + 有坐席回复 + 超过3分钟未回复 + 未发送过提醒 reminder_threshold = datetime.now() - timedelta(minutes=REMINDER_TIMEOUT_MINUTES) reminder_stmt = select(Conversation).where( @@ -56,12 +67,11 @@ async def check_unreplied_sessions(): logger.info(f"发现 {len(sessions_to_remind)} 个需要发送提醒的会话") - # 2. 发送企微提醒消息 + # 1b. 发送企微提醒消息 for session in sessions_to_remind: try: success = await send_reminder_message(session.employee_id) if success: - # 3. 标记已发送提醒 session.reminder_sent = True session.reminder_sent_at = datetime.now() logger.info(f"会话 {session.id} 已发送提醒: employee_id={session.employee_id}") @@ -71,8 +81,8 @@ async def check_unreplied_sessions(): logger.error(f"会话 {session.id} 发送提醒异常: {e}") continue - # 4. 查找需要标记待关闭的会话 - # 条件:active 状态 + 有坐席回复 + 超过10分钟未回复 + # 1c. 查找需要标记待关闭的会话 + # 条件:serving + 有坐席回复 + 超过10分钟未回复 close_threshold = datetime.now() - timedelta(minutes=CLOSE_TIMEOUT_MINUTES) close_stmt = select(Conversation).where( @@ -85,11 +95,81 @@ async def check_unreplied_sessions(): logger.info(f"发现 {len(sessions_to_close)} 个需要标记待关闭的会话") - # 5. 更新会话状态为 pending_close + # 1d. 更新会话状态为 pending_close for session in sessions_to_close: session.status = "pending_close" + session.pending_close_at = datetime.now() logger.info(f"会话 {session.id} 已标记为待关闭: employee_id={session.employee_id}") + # ================================================================== + # 2. pending_close 状态:超时自动 resolved(新增,决策 G4) + # ================================================================== + # 坐席发起结单后,员工5分钟内未确认 → 系统自动关闭 + pending_close_sessions = await closing_service.get_pending_close_timeout_sessions() + + logger.info(f"发现 {len(pending_close_sessions)} 个 pending_close 超时会话") + + for session in pending_close_sessions: + try: + await closing_service.auto_timeout_close( + str(session.id), timeout_type="pending_close" + ) + logger.info(f"会话 {session.id} pending_close 超时已自动关闭") + except Exception as e: + logger.error(f"会话 {session.id} pending_close 自动关闭失败: {e}") + continue + + # ================================================================== + # 3. ai_handling 状态:超时提醒 + 自动关闭(新增,决策 G4) + # ================================================================== + # AI处理阶段30分钟无互动 → 发送提醒 + # 提醒后10分钟仍无响应 → 自动关闭 + + # 3a. 发送 ai_handling 超时提醒 + ai_reminder_sessions = await closing_service.get_ai_handling_reminder_sessions() + + logger.info(f"发现 {len(ai_reminder_sessions)} 个 ai_handling 需要提醒的会话") + + for session in ai_reminder_sessions: + try: + # 推送 WS 超时警告 + from app.services.ws_manager import manager as ws_manager + await ws_manager.send_to_employee( + session.employee_id, + { + "type": "auto_close_warning", + "data": { + "conversation_id": str(session.id), + "minutes_remaining": AI_HANDLING_REMINDER_TO_CLOSE_MINUTES, + "message": f"您的会话已空闲超过{AI_HANDLING_TIMEOUT_MINUTES}分钟," + f"将在{AI_HANDLING_REMINDER_TO_CLOSE_MINUTES}分钟后自动关闭。" + f"如需继续请发送消息。", + "timestamp": datetime.now().isoformat(), + }, + } + ) + session.reminder_sent = True + session.reminder_sent_at = datetime.now() + logger.info(f"会话 {session.id} ai_handling 超时提醒已发送") + except Exception as e: + logger.error(f"会话 {session.id} ai_handling 提醒发送失败: {e}") + continue + + # 3b. ai_handling 超时自动关闭 + ai_timeout_sessions = await closing_service.get_ai_handling_timeout_sessions() + + logger.info(f"发现 {len(ai_timeout_sessions)} 个 ai_handling 超时关闭会话") + + for session in ai_timeout_sessions: + try: + await closing_service.auto_timeout_close( + str(session.id), timeout_type="ai_handling" + ) + logger.info(f"会话 {session.id} ai_handling 超时已自动关闭") + except Exception as e: + logger.error(f"会话 {session.id} ai_handling 自动关闭失败: {e}") + continue + # 提交数据库变更 await db.commit() logger.info("超时检查任务执行完成") diff --git a/backend/create_meetingroom_tables.py b/backend/create_meetingroom_tables.py new file mode 100644 index 0000000..d8957b8 --- /dev/null +++ b/backend/create_meetingroom_tables.py @@ -0,0 +1,58 @@ +"""创建会议室预定相关表 - 直接 SQL 执行(绕过 alembic)""" +import asyncio +from sqlalchemy import text +from app.database import _get_engine + +SQL = """ +-- 1. 终端-会议室绑定表 +CREATE TABLE IF NOT EXISTS terminal_room_binding ( + id SERIAL PRIMARY KEY, + terminal_sn VARCHAR(64) NOT NULL, + terminal_name VARCHAR(100) NOT NULL DEFAULT '', + meetingroom_id INTEGER NOT NULL, + meetingroom_name VARCHAR(100) NOT NULL DEFAULT '', + location VARCHAR(200) NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS ix_terminal_room_binding_terminal_sn ON terminal_room_binding (terminal_sn); +CREATE INDEX IF NOT EXISTS ix_terminal_room_binding_meetingroom_id ON terminal_room_binding (meetingroom_id); + +-- 2. 会议室预定记录快照表 +CREATE TABLE IF NOT EXISTS meetingroom_booking_snapshot ( + id SERIAL PRIMARY KEY, + meetingroom_id INTEGER NOT NULL, + booking_id VARCHAR(64) NOT NULL, + subject VARCHAR(200) NOT NULL DEFAULT '', + booker VARCHAR(64) NOT NULL DEFAULT '', + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + snapshot_date DATE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS ix_meetingroom_booking_snapshot_meetingroom_id ON meetingroom_booking_snapshot (meetingroom_id); +CREATE INDEX IF NOT EXISTS ix_meetingroom_booking_snapshot_booking_id ON meetingroom_booking_snapshot (booking_id); +CREATE INDEX IF NOT EXISTS ix_meetingroom_booking_snapshot_snapshot_date ON meetingroom_booking_snapshot (snapshot_date); +""" + +async def main(): + engine = _get_engine() + async with engine.begin() as conn: + for stmt in SQL.strip().split(';'): + stmt = stmt.strip() + if stmt and not stmt.startswith('--'): + print(f" Executing: {stmt[:60]}...") + await conn.execute(text(stmt)) + print("\n✅ All tables created successfully!") + + # Verify + result = await conn.execute(text( + "SELECT table_name FROM information_schema.tables " + "WHERE table_name IN ('terminal_room_binding', 'meetingroom_booking_snapshot')" + )) + tables = [r[0] for r in result] + print(f"Verified tables: {tables}") + +asyncio.run(main()) diff --git a/backend/tests/test_exclusion.py b/backend/tests/test_exclusion.py new file mode 100644 index 0000000..5539a04 --- /dev/null +++ b/backend/tests/test_exclusion.py @@ -0,0 +1,820 @@ +# -*- coding: utf-8 -*- +"""代答排除模块回归测试 — T04 + +测试范围: + Matcher单元测试(12个): keyword(5) + regex(3) + intent(2) + category(2) + ExclusionService测试(5个): no_rules, priority_order, first_hit_stops, + logs_hit, test_match_no_logging + API测试(8个): create, duplicate_name, get_detail, update, delete, + toggle, test_match, get_stats + ai_handler集成测试(3个): exclusion_hit, exclusion_miss, exclusion_error + +测试依赖: conftest.py 提供的 client / db_session fixtures +""" + +import uuid +from contextlib import ExitStack +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.exclusion_rule import ExclusionRule +from app.models.exclusion_log import ExclusionLog +from app.models.triage_session import TriageSession + + +# ============================================================================ +# 辅助函数 +# ============================================================================ + +async def _login_admin(client: AsyncClient, db_session: AsyncSession) -> str: + """创建 admin 角色用户并返回 Bearer token。""" + from app.models.role import Role + from app.models.user_role import UserRole + + admin_id = f"test_admin_{uuid.uuid4().hex[:8]}" + + # 确保 admin 角色存在 + stmt = select(Role).where(Role.name == "admin") + result = await db_session.execute(stmt) + admin_role = result.scalars().first() + if not admin_role: + admin_role = Role( + name="admin", display_name="管理员", + description="系统管理员", permissions=[], + ) + db_session.add(admin_role) + await db_session.flush() + + # 创建 UserRole 关联 + db_session.add(UserRole( + employee_id=admin_id, role_id=admin_role.id, + source="manual", assigned_by="test_fixture", + )) + await db_session.flush() + + # 登录 + resp = await client.post("/agents/login", json={ + "user_id": admin_id, "name": "测试管理员", + }) + return resp.json()["data"]["token"] + + +async def _create_exclusion_rule(db_session: AsyncSession, **kwargs) -> ExclusionRule: + """在数据库中创建排除规则。 + + Args: + db_session: 数据库会话 + **kwargs: 覆盖默认字段值 + + Returns: + ExclusionRule: 创建的规则对象 + """ + defaults = { + "rule_name": f"规则-{uuid.uuid4().hex[:8]}", + "rule_description": "测试规则", + "priority": "P2", + "match_type": "keyword", + "match_condition": "密码", + "match_scope": [], + "action_type": "transfer_human", + "transfer_message": "已为您转接人工坐席", + "status": "enabled", + "hit_count": 0, + "created_by": "test_admin", + } + defaults.update(kwargs) + rule = ExclusionRule(**defaults) + db_session.add(rule) + await db_session.flush() + return rule + + +# ============================================================================ +# Section A — Matcher 单元测试(12个) +# ============================================================================ + +class TestKeywordMatcher: + """关键词匹配器测试。""" + + @pytest.mark.asyncio + async def test_keyword_match_hit(self): + """关键词命中。""" + from app.services.matchers.keyword_matcher import KeywordMatcher + + matcher = KeywordMatcher() + result = await matcher.match( + message="我的密码过期了怎么办", + condition="密码过期,账号锁定", + ) + assert result.matched is True + assert "密码过期" in result.matched_detail + + @pytest.mark.asyncio + async def test_keyword_match_miss(self): + """关键词未命中。""" + from app.services.matchers.keyword_matcher import KeywordMatcher + + matcher = KeywordMatcher() + result = await matcher.match( + message="今天天气真好", + condition="密码过期,账号锁定", + ) + assert result.matched is False + + @pytest.mark.asyncio + async def test_keyword_match_case_insensitive(self): + """大小写不敏感匹配。""" + from app.services.matchers.keyword_matcher import KeywordMatcher + + matcher = KeywordMatcher() + result = await matcher.match( + message="VPN connection failed", + condition="vpn,password", + ) + assert result.matched is True + assert "vpn" in result.matched_detail.lower() + + @pytest.mark.asyncio + async def test_keyword_match_multiple_keywords(self): + """多关键词逗号分隔,任一命中即匹配。""" + from app.services.matchers.keyword_matcher import KeywordMatcher + + matcher = KeywordMatcher() + # 第三个关键词命中 + result = await matcher.match( + message="打印机卡纸了", + condition="密码过期,账号锁定,打印机", + ) + assert result.matched is True + assert "打印机" in result.matched_detail + + @pytest.mark.asyncio + async def test_keyword_match_empty_message(self): + """空消息返回未命中。""" + from app.services.matchers.keyword_matcher import KeywordMatcher + + matcher = KeywordMatcher() + result = await matcher.match(message="", condition="密码") + assert result.matched is False + + # 空条件也应返回未命中 + result = await matcher.match(message="密码过期", condition="") + assert result.matched is False + + +class TestRegexMatcher: + """正则匹配器测试。 + + 注意: RegexMatcher 使用 signal.SIGALRM 做 ReDoS 超时保护, + Windows 不支持 SIGALRM。测试通过 fixture patch signal 模块 + 使正则匹配在 Windows 上正常工作。 + 源码 regex_matcher.py 在 Windows 上存在兼容性问题 + (SIGALRM 不可用时直接返回未命中,应降级为无超时匹配)。 + """ + + @pytest.fixture(autouse=True) + def _patch_signal(self): + """Windows 兼容: patch signal.SIGALRM 使正则匹配器正常工作。""" + import signal as sig + if not hasattr(sig, "SIGALRM"): + with ExitStack() as stack: + stack.enter_context(patch.object(sig, "SIGALRM", 14, create=True)) + stack.enter_context(patch.object(sig, "ITIMER_REAL", 0, create=True)) + if not hasattr(sig, "setitimer"): + stack.enter_context( + patch.object(sig, "setitimer", create=True, return_value=None) + ) + stack.enter_context( + patch.object(sig, "signal", return_value=sig.SIG_DFL) + ) + yield + else: + yield + + @pytest.mark.asyncio + async def test_regex_match_hit(self): + """正则命中。""" + from app.services.matchers.regex_matcher import RegexMatcher + + matcher = RegexMatcher() + result = await matcher.match( + message="我的密码好像过期了", + condition="密码.*过期", + ) + assert result.matched is True + assert "密码" in result.matched_detail + + @pytest.mark.asyncio + async def test_regex_match_miss(self): + """正则未命中。""" + from app.services.matchers.regex_matcher import RegexMatcher + + matcher = RegexMatcher() + result = await matcher.match( + message="今天天气真好", + condition="密码.*过期", + ) + assert result.matched is False + + @pytest.mark.asyncio + async def test_regex_match_invalid_pattern(self): + """无效正则返回未命中(不抛异常)。""" + from app.services.matchers.regex_matcher import RegexMatcher + + matcher = RegexMatcher() + # 无效正则括号不匹配 + result = await matcher.match( + message="测试消息", + condition="[unclosed", + ) + assert result.matched is False + + +class TestIntentMatcher: + """意图匹配器测试。""" + + @pytest.mark.asyncio + async def test_intent_match_dify_unavailable(self): + """Dify 不可用时降级返回未命中。""" + from app.services.matchers.intent_matcher import IntentMatcher + + matcher = IntentMatcher() + # Mock _recognize_intent 返回 None(Dify 不可用) + with patch.object(matcher, "_recognize_intent", return_value=None): + result = await matcher.match( + message="我的密码忘了", + condition="password_reset,account_unlock", + ) + assert result.matched is False + + @pytest.mark.asyncio + async def test_intent_match_hit(self): + """意图命中(mock Dify 返回匹配的意图)。""" + from app.services.matchers.intent_matcher import IntentMatcher + + matcher = IntentMatcher() + # Mock _recognize_intent 返回 password_reset + with patch.object(matcher, "_recognize_intent", return_value="password_reset"): + result = await matcher.match( + message="我的密码忘了,帮我重置一下", + condition="password_reset,account_unlock", + ) + assert result.matched is True + assert "password_reset" in result.matched_detail + + +class TestCategoryMatcher: + """分类匹配器测试。""" + + @pytest.mark.asyncio + async def test_category_match_hit(self, db_session: AsyncSession): + """分类命中 — 分诊记录的 problem_category 在排除列表中。""" + from app.services.matchers.category_matcher import CategoryMatcher + + conv_id = f"conv-cat-{uuid.uuid4().hex[:8]}" + # 创建分诊记录,problem_category = "Outlook" + triage = TriageSession( + conversation_id=conv_id, + user_id="test_user", + user_name="测试", + request_title="测试", + request_content="测试内容", + source="wecom_h5", + status="routed", + urgency="low", + problem_category="Outlook", + ) + db_session.add(triage) + await db_session.flush() + + matcher = CategoryMatcher() + result = await matcher.match( + message="测试消息", + condition="Outlook,VPN,打印机", + context={"conversation_id": conv_id, "db": db_session}, + ) + assert result.matched is True + assert "Outlook" in result.matched_detail + + @pytest.mark.asyncio + async def test_category_match_no_triage(self, db_session: AsyncSession): + """无分诊记录返回未命中(软依赖)。""" + from app.services.matchers.category_matcher import CategoryMatcher + + matcher = CategoryMatcher() + # 使用不存在的 conversation_id + result = await matcher.match( + message="测试消息", + condition="Outlook,VPN", + context={"conversation_id": "non-existent-conv", "db": db_session}, + ) + assert result.matched is False + + +# ============================================================================ +# Section B — ExclusionService 测试(5个) +# ============================================================================ + +class TestExclusionService: + """ExclusionService 责任链匹配引擎测试。""" + + @pytest.mark.asyncio + async def test_check_exclusions_no_rules(self, db_session: AsyncSession): + """无规则时返回未命中。""" + from app.services.exclusion_service import ExclusionService + + service = ExclusionService() + result = await service.check_exclusions( + db=db_session, + message="测试消息", + conversation_id="conv-001", + user_id="user-001", + ) + assert result.matched is False + + @pytest.mark.asyncio + async def test_check_exclusions_priority_order(self, db_session: AsyncSession): + """优先级排序 P0 > P1 — P0 规则先匹配。""" + from app.services.exclusion_service import ExclusionService + + # 创建两条规则,P1 和 P0,消息同时包含两个关键词 + await _create_exclusion_rule( + db_session, + rule_name="P1规则-密码", + priority="P1", + match_condition="密码", + ) + await _create_exclusion_rule( + db_session, + rule_name="P0规则-宕机", + priority="P0", + match_condition="宕机", + ) + await db_session.flush() + + service = ExclusionService() + result = await service.check_exclusions( + db=db_session, + message="系统宕机了,密码也忘了", + conversation_id="conv-002", + user_id="user-002", + ) + assert result.matched is True + # P0 规则应先匹配 + assert result.rule_name == "P0规则-宕机" + + @pytest.mark.asyncio + async def test_check_exclusions_first_hit_stops(self, db_session: AsyncSession): + """命中即停止 — 只记录一条日志。""" + from app.services.exclusion_service import ExclusionService + + # 创建两条都能匹配的规则 + await _create_exclusion_rule( + db_session, + rule_name="规则A-密码", + priority="P0", + match_condition="密码", + ) + await _create_exclusion_rule( + db_session, + rule_name="规则B-密码", + priority="P1", + match_condition="密码", + ) + await db_session.flush() + + service = ExclusionService() + result = await service.check_exclusions( + db=db_session, + message="密码过期了", + conversation_id="conv-003", + user_id="user-003", + ) + assert result.matched is True + # 只有 P0 规则应命中 + assert result.rule_name == "规则A-密码" + + # 验证只创建了一条日志 + log_result = await db_session.execute(select(ExclusionLog)) + logs = log_result.scalars().all() + assert len(logs) == 1 + assert logs[0].rule_name == "规则A-密码" + + @pytest.mark.asyncio + async def test_check_exclusions_logs_hit(self, db_session: AsyncSession): + """命中记录日志 + 更新 hit_count。""" + from app.services.exclusion_service import ExclusionService + + rule = await _create_exclusion_rule( + db_session, + rule_name="日志测试规则", + match_condition="密码过期", + hit_count=0, + ) + await db_session.flush() + + service = ExclusionService() + result = await service.check_exclusions( + db=db_session, + message="我的密码过期了", + conversation_id="conv-004", + user_id="user-004", + ) + assert result.matched is True + assert result.rule_name == "日志测试规则" + + # 验证 hit_count 已更新 + await db_session.refresh(rule) + assert rule.hit_count == 1 + + # 验证日志已创建 + log_result = await db_session.execute( + select(ExclusionLog).where(ExclusionLog.rule_id == rule.id) + ) + logs = log_result.scalars().all() + assert len(logs) == 1 + assert logs[0].action_type == "transfer_human" + + @pytest.mark.asyncio + async def test_test_match_no_logging(self, db_session: AsyncSession): + """test_match 不记录日志、不更新 hit_count。""" + from app.services.exclusion_service import ExclusionService + + rule = await _create_exclusion_rule( + db_session, + rule_name="测试匹配规则", + match_condition="密码过期", + hit_count=0, + ) + await db_session.flush() + + service = ExclusionService() + result = await service.test_match( + db=db_session, + message="我的密码过期了", + rule_id=rule.id, + ) + assert result.matched is True + + # 验证 hit_count 未更新 + await db_session.refresh(rule) + assert rule.hit_count == 0 + + # 验证无日志创建 + log_result = await db_session.execute( + select(ExclusionLog).where(ExclusionLog.rule_id == rule.id) + ) + logs = log_result.scalars().all() + assert len(logs) == 0 + + +# ============================================================================ +# Section C — API 测试(8个) +# ============================================================================ + +class TestExclusionAPI: + """代答排除管理 API 测试。""" + + @pytest.mark.asyncio + async def test_create_rule_success(self, client, db_session): + """新建规则成功。""" + token = await _login_admin(client, db_session) + + resp = await client.post( + "/admin/exclusion-rules", + json={ + "rule_name": "测试规则-新建", + "rule_description": "测试描述", + "priority": "P1", + "match_type": "keyword", + "match_condition": "密码,账号", + "match_scope": [], + "action_type": "transfer_human", + "transfer_message": "已转人工", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + rule = data["data"] + assert rule["rule_name"] == "测试规则-新建" + assert rule["status"] == "enabled" + assert rule["hit_count"] == 0 + + @pytest.mark.asyncio + async def test_create_rule_duplicate_name(self, client, db_session): + """规则名重复返回 400。""" + token = await _login_admin(client, db_session) + + # 先创建一条规则 + await _create_exclusion_rule(db_session, rule_name="重复规则名") + await db_session.flush() + + # 再用同名创建 + resp = await client.post( + "/admin/exclusion-rules", + json={ + "rule_name": "重复规则名", + "priority": "P2", + "match_type": "keyword", + "match_condition": "测试", + "action_type": "transfer_human", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 400 + assert "已存在" in data["message"] + + @pytest.mark.asyncio + async def test_get_rule_detail(self, client, db_session): + """规则详情。""" + token = await _login_admin(client, db_session) + + rule = await _create_exclusion_rule( + db_session, + rule_name="详情测试规则", + match_type="keyword", + match_condition="密码", + ) + await db_session.flush() + + resp = await client.get( + f"/admin/exclusion-rules/{rule.id}", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["id"] == rule.id + assert data["data"]["rule_name"] == "详情测试规则" + + @pytest.mark.asyncio + async def test_update_rule(self, client, db_session): + """编辑规则。""" + token = await _login_admin(client, db_session) + + rule = await _create_exclusion_rule( + db_session, + rule_name="编辑前规则", + ) + await db_session.flush() + + resp = await client.put( + f"/admin/exclusion-rules/{rule.id}", + json={ + "rule_name": "编辑后规则", + "match_condition": "VPN,网络", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["rule_name"] == "编辑后规则" + assert data["data"]["match_condition"] == "VPN,网络" + + @pytest.mark.asyncio + async def test_delete_rule(self, client, db_session): + """删除规则。""" + token = await _login_admin(client, db_session) + + rule = await _create_exclusion_rule( + db_session, + rule_name="待删除规则", + ) + await db_session.flush() + rule_id = rule.id + + resp = await client.delete( + f"/admin/exclusion-rules/{rule_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + + # 验证已删除 + resp = await client.get( + f"/admin/exclusion-rules/{rule_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.json()["code"] == 404 + + @pytest.mark.asyncio + async def test_toggle_rule(self, client, db_session): + """启用/停用规则。""" + token = await _login_admin(client, db_session) + + rule = await _create_exclusion_rule( + db_session, + rule_name="切换状态规则", + status="enabled", + ) + await db_session.flush() + + # 停用 + resp = await client.post( + f"/admin/exclusion-rules/{rule.id}/toggle", + json={"status": "disabled"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["status"] == "disabled" + + # 再启用 + resp = await client.post( + f"/admin/exclusion-rules/{rule.id}/toggle", + json={"status": "enabled"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + assert resp.json()["data"]["status"] == "enabled" + + @pytest.mark.asyncio + async def test_test_match_api(self, client, db_session): + """测试匹配 API。""" + token = await _login_admin(client, db_session) + + await _create_exclusion_rule( + db_session, + rule_name="API测试匹配规则", + match_type="keyword", + match_condition="密码过期", + ) + await db_session.flush() + + resp = await client.post( + "/admin/exclusion-rules/test", + json={"message": "我的密码过期了"}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["matched"] is True + assert data["data"]["rule_name"] == "API测试匹配规则" + + @pytest.mark.asyncio + async def test_get_stats_api(self, client, db_session): + """统计概要 API。""" + token = await _login_admin(client, db_session) + + # 创建规则 + await _create_exclusion_rule(db_session, status="enabled") + await _create_exclusion_rule(db_session, status="disabled") + await db_session.flush() + + resp = await client.get( + "/admin/exclusion-rules/stats", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + stats = data["data"] + assert "enabled_count" in stats + assert "disabled_count" in stats + assert "monthly_hits" in stats + assert "monthly_transfers" in stats + assert stats["enabled_count"] == 1 + assert stats["disabled_count"] == 1 + + +# ============================================================================ +# Section D — ai_handler 集成测试(3个) +# ============================================================================ + +class TestAIHandlerExclusion: + """AI 回复处理器与代答排除的集成测试。 + + 验证 ai_handler.handle_message 在 AI 回复前检查排除规则: + 1. 命中排除规则 → 拦截 AI 回复,返回排除结果 + 2. 未命中 → 正常调用 AI + 3. 排除检查异常 → 降级继续 AI 回复(不阻断主流程) + """ + + @pytest.mark.asyncio + async def test_ai_handler_exclusion_hit(self, db_session: AsyncSession): + """命中排除规则 — AI 回复前拦截,不调用 AI 服务。""" + from app.services.ai_handler import AIHandler + + # 创建排除规则(关键词 "密码" → transfer_human) + await _create_exclusion_rule( + db_session, + rule_name="密码排除规则", + match_type="keyword", + match_condition="密码", + action_type="transfer_human", + transfer_message="此问题需转人工处理", + ) + await db_session.flush() + + # 创建 AIHandler with mock AIService + mock_ai_service = AsyncMock() + handler = AIHandler(ai_service=mock_ai_service) + + result = await handler.handle_message( + content="我的密码过期了怎么办", + conversation_id="conv-ai-001", + user_id="user-001", + db=db_session, + ) + + # 验证:命中排除规则,返回 excluded 类型 + assert result.reply_type == "excluded" + assert result.should_transfer is True + assert result.should_count is False + # AI 服务不应被调用 + mock_ai_service.get_reply.assert_not_called() + + @pytest.mark.asyncio + async def test_ai_handler_exclusion_miss(self, db_session: AsyncSession): + """未命中排除规则 — 正常调用 AI 服务。""" + from app.services.ai_handler import AIHandler + + # 创建排除规则(关键词 "密码") + await _create_exclusion_rule( + db_session, + rule_name="密码排除规则", + match_type="keyword", + match_condition="密码", + ) + await db_session.flush() + + # 创建 AIHandler with mock AIService + mock_ai_service = AsyncMock() + mock_ai_service.get_reply.return_value = { + "hit": True, + "content": "建议您重启电脑试试", + "conversation_id": "dify-conv-001", + } + handler = AIHandler(ai_service=mock_ai_service) + + # 消息不含 "密码" → 不命中排除规则 → 正常 AI 回复 + result = await handler.handle_message( + content="打印机怎么连接", + conversation_id="conv-ai-002", + user_id="user-002", + db=db_session, + ) + + # 验证:正常 AI 回复 + assert result.reply_type == "ai_hit" + assert result.should_count is True + assert "重启电脑" in result.content + # AI 服务应被调用 + mock_ai_service.get_reply.assert_called_once() + + @pytest.mark.asyncio + async def test_ai_handler_exclusion_error(self, db_session: AsyncSession): + """排除检查异常不阻断主流程 — 降级继续 AI 回复。""" + from app.services.ai_handler import AIHandler + + # 创建 AIHandler with mock AIService + mock_ai_service = AsyncMock() + mock_ai_service.get_reply.return_value = { + "hit": True, + "content": "AI降级回复", + "conversation_id": "dify-conv-002", + } + handler = AIHandler(ai_service=mock_ai_service) + + # Patch check_exclusions 抛出异常 + with patch( + "app.services.exclusion_service.get_exclusion_service" + ) as mock_get_service: + mock_service = AsyncMock() + mock_service.check_exclusions.side_effect = Exception("DB connection error") + mock_get_service.return_value = mock_service + + result = await handler.handle_message( + content="打印机问题", + conversation_id="conv-ai-003", + user_id="user-003", + db=db_session, + ) + + # 验证:排除检查异常后降级继续 AI 回复 + assert result.reply_type == "ai_hit" + assert result.should_count is True + # AI 服务应被调用(降级不阻断) + mock_ai_service.get_reply.assert_called_once() diff --git a/backend/tests/test_triage.py b/backend/tests/test_triage.py new file mode 100644 index 0000000..2252f76 --- /dev/null +++ b/backend/tests/test_triage.py @@ -0,0 +1,601 @@ +# -*- coding: utf-8 -*- +"""分诊交互模块回归测试 — T02 + +测试范围: + H5端接口(7个): start_success, start_timeout, start_dify_unavailable, + submit_step, skip_step, transfer, complete + 坐席端接口(6个): list_pending, get_stats, get_detail, route_session, + get_history, exclude_options_ws_push + Service层(2个): determine_urgency_keywords, determine_urgency_confidence + +测试依赖: conftest.py 提供的 client / db_session fixtures +""" + +import asyncio +import uuid +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.triage_session import TriageSession +from app.services.triage_service import TriageService + + +# ============================================================================ +# 辅助函数 +# ============================================================================ + +async def _login(client: AsyncClient, db_session: AsyncSession, role: str = "agent") -> str: + """登录并返回 Bearer token。 + + Args: + client: 测试客户端 + db_session: 数据库会话 + role: 角色(agent/admin) + + Returns: + str: Bearer token + """ + from app.models.role import Role + from app.models.user_role import UserRole + + user_id = f"test_{role}_{uuid.uuid4().hex[:8]}" + + # 确保角色存在 + stmt = select(Role).where(Role.name == role) + result = await db_session.execute(stmt) + db_role = result.scalars().first() + if not db_role: + display = "坐席" if role == "agent" else "管理员" + db_role = Role( + name=role, display_name=display, + description=f"{display}角色", permissions=[], + ) + db_session.add(db_role) + await db_session.flush() + + # 创建 UserRole 关联 + db_session.add(UserRole( + employee_id=user_id, + role_id=db_role.id, + source="manual", + assigned_by="test_fixture", + )) + await db_session.flush() + + # 登录 + resp = await client.post("/agents/login", json={ + "user_id": user_id, + "name": f"测试{role}", + }) + return resp.json()["data"]["token"] + + +async def _create_triage_session(db_session: AsyncSession, **kwargs) -> TriageSession: + """在数据库中创建分诊会话记录。 + + Args: + db_session: 数据库会话 + **kwargs: 覆盖默认字段值 + + Returns: + TriageSession: 创建的会话对象 + """ + defaults = { + "conversation_id": f"conv-{uuid.uuid4().hex[:8]}", + "user_id": "test_user_001", + "user_name": "测试用户", + "user_dept": "技术部", + "request_title": "测试问题标题", + "request_content": "测试问题内容", + "source": "wecom_h5", + "status": "pending", + "urgency": "medium", + } + defaults.update(kwargs) + session = TriageSession(**defaults) + db_session.add(session) + await db_session.flush() + return session + + +# ============================================================================ +# Fixture: Mock Dify 分诊服务 +# ============================================================================ + +@pytest.fixture +def mock_dify_triage(): + """Mock Dify triage service on the singleton TriageService。 + + TriageService 是单例,dify_service 在 __init__ 中赋值。 + 此 fixture 替换 dify_service 为 AsyncMock,测试后恢复原值。 + """ + from app.services.triage_service import get_triage_service + service = get_triage_service() + original = service.dify_service + mock = AsyncMock() + service.dify_service = mock + yield mock + service.dify_service = original + + +# ============================================================================ +# Section A — H5 端接口测试(7个) +# ============================================================================ + +class TestH5Triage: + """H5 端分诊交互接口测试。""" + + @pytest.mark.asyncio + async def test_start_triage_success(self, client, db_session, mock_dify_triage): + """正常发起分诊 — code=0, 返回 triage_id/steps/confidence/urgency。""" + token = await _login(client, db_session) + + mock_dify_triage.analyze.return_value = { + "confidence": 0.85, + "urgency": "medium", + "suggested_route": "ai_self", + "problem_type": "软件", + "problem_category": "Outlook", + "matched_knowledge": "FAQ-001", + "match_score": 0.92, + "context_tags": ["email"], + "triage_steps": [ + { + "question": "您遇到的问题是?", + "options": [ + {"label": "无法登录", "probability": 0.7}, + {"label": "邮件发不出", "probability": 0.3}, + ], + } + ], + } + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-test-001", + "question": "我的Outlook打不开了", + }, headers={"Authorization": f"Bearer {token}"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + result = data["data"] + assert "triage_id" in result + assert len(result["steps"]) == 1 + assert result["confidence"] == 0.85 + # "打不开" 是中级关键词 → medium + assert result["urgency"] == "medium" + + @pytest.mark.asyncio + async def test_start_triage_timeout(self, client, db_session, mock_dify_triage): + """Dify 超时 — status=timeout, 自动转人工。""" + token = await _login(client, db_session) + + # 模拟 Dify 超时(asyncio.wait_for 捕获 TimeoutError) + mock_dify_triage.analyze.side_effect = asyncio.TimeoutError() + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-test-002", + "question": "密码过期了怎么办", + }, headers={"Authorization": f"Bearer {token}"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["status"] == "timeout" + assert "triage_id" in data["data"] + + @pytest.mark.asyncio + async def test_start_triage_dify_unavailable(self, client, db_session, mock_dify_triage): + """Dify 不可用 — 降级转人工。""" + token = await _login(client, db_session) + + # 模拟 Dify 不可用(RuntimeError 触发降级转人工) + mock_dify_triage.analyze.side_effect = RuntimeError("Dify unavailable") + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-test-003", + "question": "VPN连不上了", + }, headers={"Authorization": f"Bearer {token}"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["status"] == "timeout" + assert "triage_id" in data["data"] + + @pytest.mark.asyncio + async def test_submit_step_success(self, client, db_session, mock_dify_triage): + """提交步骤选择 — 返回 next_step 和 collected_context。""" + token = await _login(client, db_session) + + # 先发起分诊 + mock_dify_triage.analyze.return_value = { + "confidence": 0.8, + "urgency": "low", + "triage_steps": [ + {"question": "问题1", "options": [{"label": "选项A", "probability": 0.6}]}, + {"question": "问题2", "options": [{"label": "选项B", "probability": 0.5}]}, + ], + } + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-step-001", + "question": "打印机问题", + }, headers={"Authorization": f"Bearer {token}"}) + triage_id = resp.json()["data"]["triage_id"] + + # 提交步骤0的选择 + resp = await client.post("/h5/triage/step", json={ + "triage_id": triage_id, + "step_index": 0, + "selected_label": "选项A", + }, headers={"Authorization": f"Bearer {token}"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["next_step"] is not None + assert data["data"]["next_step"]["question"] == "问题2" + assert "选项A" in data["data"]["collected_context"] + + @pytest.mark.asyncio + async def test_skip_step_success(self, client, db_session, mock_dify_triage): + """跳过步骤 — 返回 next_step。""" + token = await _login(client, db_session) + + mock_dify_triage.analyze.return_value = { + "confidence": 0.7, + "urgency": "low", + "triage_steps": [ + {"question": "问题1", "options": [{"label": "选项A", "probability": 0.6}]}, + {"question": "问题2", "options": [{"label": "选项B", "probability": 0.5}]}, + ], + } + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-skip-001", + "question": "网络问题", + }, headers={"Authorization": f"Bearer {token}"}) + triage_id = resp.json()["data"]["triage_id"] + + resp = await client.post("/h5/triage/skip", json={ + "triage_id": triage_id, + "step_index": 0, + }, headers={"Authorization": f"Bearer {token}"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["next_step"] is not None + assert data["data"]["next_step"]["question"] == "问题2" + + @pytest.mark.asyncio + async def test_transfer_to_human_success(self, client, db_session, mock_dify_triage): + """转人工 — status=waiting_agent。""" + token = await _login(client, db_session) + + mock_dify_triage.analyze.return_value = { + "confidence": 0.6, + "urgency": "low", + "triage_steps": [{"question": "问题1", "options": []}], + } + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-transfer-001", + "question": "硬件问题", + }, headers={"Authorization": f"Bearer {token}"}) + triage_id = resp.json()["data"]["triage_id"] + + resp = await client.post("/h5/triage/transfer", json={ + "triage_id": triage_id, + "context": ["用户选择的上下文"], + }, headers={"Authorization": f"Bearer {token}"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["status"] == "waiting_agent" + assert "conversation_id" in data["data"] + + @pytest.mark.asyncio + async def test_complete_triage_success(self, client, db_session, mock_dify_triage): + """分诊完成 — 返回 reply 和 confidence。""" + token = await _login(client, db_session) + + mock_dify_triage.analyze.return_value = { + "confidence": 0.9, + "urgency": "low", + "triage_steps": [ + {"question": "问题1", "options": [{"label": "选项A", "probability": 0.8}]} + ], + } + mock_dify_triage.generate_reply.return_value = { + "reply": "建议您重启Outlook客户端。", + "confidence": 0.88, + } + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-complete-001", + "question": "软件使用问题", + }, headers={"Authorization": f"Bearer {token}"}) + triage_id = resp.json()["data"]["triage_id"] + + resp = await client.post("/h5/triage/complete", json={ + "triage_id": triage_id, + "context": ["选项A"], + }, headers={"Authorization": f"Bearer {token}"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert "reply" in data["data"] + assert data["data"]["confidence"] == 0.88 + + +# ============================================================================ +# Section B — 坐席端接口测试(6个) +# ============================================================================ + +class TestAgentTriage: + """坐席端分诊看板接口测试。""" + + @pytest.mark.asyncio + async def test_list_pending_sorted_by_urgency(self, client, db_session): + """待分诊列表按紧急度排序 high > medium > low。""" + token = await _login(client, db_session) + + # 创建3条不同紧急度的待分诊记录(创建顺序故意打乱) + await _create_triage_session(db_session, urgency="low", request_title="低优先级") + await _create_triage_session(db_session, urgency="high", request_title="高优先级") + await _create_triage_session(db_session, urgency="medium", request_title="中优先级") + await db_session.flush() + + resp = await client.get( + "/agent/triage/pending", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + items = data["data"]["items"] + assert len(items) == 3 + # high 应排在最前 + assert items[0]["urgency"] == "high" + assert items[1]["urgency"] == "medium" + assert items[2]["urgency"] == "low" + + @pytest.mark.asyncio + async def test_get_stats(self, client, db_session): + """统计概要返回6项指标。""" + token = await _login(client, db_session) + + # 创建测试数据 + await _create_triage_session(db_session, status="pending", urgency="high") + await _create_triage_session(db_session, status="triaging", urgency="medium") + await _create_triage_session( + db_session, status="routed", route_action="ai_self", + operated_at=datetime.now(), + ) + await db_session.flush() + + resp = await client.get( + "/agent/triage/stats", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + stats = data["data"] + # 验证6项指标字段都存在 + assert "pending_total" in stats + assert "today_triaged" in stats + assert "ai_self_count" in stats + assert "human_count" in stats + assert "auto_approval_count" in stats + assert "avg_duration_sec" in stats + # 验证待分诊数(1 pending + 1 triaging = 2) + assert stats["pending_total"] == 2 + + @pytest.mark.asyncio + async def test_get_detail(self, client, db_session, mock_dify_triage): + """获取分诊详情。""" + token = await _login(client, db_session) + + mock_dify_triage.analyze.return_value = { + "confidence": 0.85, + "urgency": "medium", + "triage_steps": [{"question": "问题1", "options": []}], + "problem_type": "软件", + "problem_category": "Outlook", + } + + # 发起分诊创建会话 + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-detail-001", + "question": "Outlook问题", + }, headers={"Authorization": f"Bearer {token}"}) + triage_id = resp.json()["data"]["triage_id"] + + # 获取详情 + resp = await client.get( + f"/agent/triage/{triage_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + detail = data["data"] + assert detail["id"] == triage_id + assert detail["problem_category"] == "Outlook" + assert detail["confidence"] == 0.85 + + @pytest.mark.asyncio + async def test_route_session(self, client, db_session, mock_dify_triage): + """坐席路由操作覆盖 AI 建议。""" + token = await _login(client, db_session) + + mock_dify_triage.analyze.return_value = { + "confidence": 0.7, + "urgency": "low", + "triage_steps": [{"question": "问题1", "options": []}], + "suggested_route": "ai_self", + } + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-route-001", + "question": "一般问题", + }, headers={"Authorization": f"Bearer {token}"}) + triage_id = resp.json()["data"]["triage_id"] + + # 坐席路由为转人工(覆盖AI建议的ai_self) + resp = await client.post( + f"/agent/triage/{triage_id}/route", + json={ + "route_action": "human", + "route_note": "需要人工排查", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["route_action"] == "human" + assert data["data"]["status"] == "routed" + assert data["data"]["route_note"] == "需要人工排查" + + @pytest.mark.asyncio + async def test_get_history(self, client, db_session): + """历史列表返回 routed/skipped/timeout 状态的记录。""" + token = await _login(client, db_session) + + # 创建历史记录 + await _create_triage_session(db_session, status="routed", route_action="ai_self") + await _create_triage_session(db_session, status="routed", route_action="human") + await _create_triage_session(db_session, status="skipped") + # pending 不应出现在历史中 + await _create_triage_session(db_session, status="pending") + await db_session.flush() + + resp = await client.get( + "/agent/triage/history", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + items = data["data"]["items"] + assert len(items) == 3 # 只有 routed/skipped + for item in items: + assert item["status"] in ("routed", "skipped", "timeout") + + @pytest.mark.asyncio + async def test_exclude_options_ws_push(self, client, db_session, mock_dify_triage): + """排除选项通过 WS 推送到 H5。""" + token = await _login(client, db_session) + + mock_dify_triage.analyze.return_value = { + "confidence": 0.8, + "urgency": "low", + "triage_steps": [{"question": "问题1", "options": [ + {"label": "选项A", "probability": 0.5}, + {"label": "选项B", "probability": 0.3}, + ]}], + } + + resp = await client.post("/h5/triage/start", json={ + "conversation_id": "conv-exclude-001", + "question": "测试问题", + }, headers={"Authorization": f"Bearer {token}"}) + triage_id = resp.json()["data"]["triage_id"] + + # Mock WS manager 的 send_to_employee 方法 + with patch( + "app.services.ws_manager.manager.send_to_employee", + new_callable=AsyncMock, + ) as mock_ws: + resp = await client.post( + f"/agent/triage/{triage_id}/exclude-options", + json={ + "excluded_labels": ["选项A"], + "recommended_label": "选项B", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 0 + assert data["data"]["excluded"] is True + # 验证 WS 推送被调用 + mock_ws.assert_called_once() + # 验证推送数据格式 + call_args = mock_ws.call_args + ws_data = call_args[0][1] # 第二个位置参数 + assert ws_data["type"] == "triage_exclude" + assert "选项A" in ws_data["data"]["excluded_labels"] + assert ws_data["data"]["recommended_label"] == "选项B" + + +# ============================================================================ +# Section C — Service 层测试(2个) +# ============================================================================ + +class TestTriageService: + """TriageService 业务逻辑测试。""" + + def test_determine_urgency_keywords(self): + """紧急度判断关键词规则。 + + 规则: + - 高级关键词(紧急/宕机/崩溃等)→ high + - 中级关键词(报错/失败/连不上等)→ medium + - 无关键词 → low + """ + # 高级关键词 → high + assert TriageService.determine_urgency("系统宕机了") == "high" + assert TriageService.determine_urgency("紧急!密码过期") == "high" + assert TriageService.determine_urgency("电脑蓝屏了") == "high" + assert TriageService.determine_urgency("系统崩溃了") == "high" + + # 中级关键词 → medium + assert TriageService.determine_urgency("VPN连不上") == "medium" + assert TriageService.determine_urgency("打印机报错") == "medium" + assert TriageService.determine_urgency("登录失败") == "medium" + assert TriageService.determine_urgency("页面打不开") == "medium" + + # 无关键词 → low + assert TriageService.determine_urgency("我想查一下工资条") == "low" + assert TriageService.determine_urgency("请问年假怎么申请") == "low" + + def test_determine_urgency_confidence(self): + """置信度低于 0.5 为 high。 + + 规则: + - confidence < 0.5 → high(即使没有关键词) + - 高级关键词始终优先于置信度 + - 置信度优先于中级关键词 + """ + # 置信度 < 0.5 → high(即使没有关键词) + assert TriageService.determine_urgency("一般问题", confidence=0.3) == "high" + assert TriageService.determine_urgency("普通咨询", confidence=0.49) == "high" + + # 置信度 >= 0.5 且无关键词 → low + assert TriageService.determine_urgency("一般问题", confidence=0.5) == "low" + assert TriageService.determine_urgency("普通咨询", confidence=0.9) == "low" + + # 置信度 < 0.5 但有中级关键词 → high(置信度优先于中级关键词) + assert TriageService.determine_urgency("VPN连不上", confidence=0.3) == "high" + + # 高级关键词始终优先(即使置信度很高) + assert TriageService.determine_urgency("宕机", confidence=0.9) == "high" + assert TriageService.determine_urgency("宕机", confidence=0.1) == "high" diff --git a/deploy-server/nginx/nginx.conf b/deploy-server/nginx/nginx.conf index 1c31c28..226f5fa 100644 --- a/deploy-server/nginx/nginx.conf +++ b/deploy-server/nginx/nginx.conf @@ -152,6 +152,8 @@ http { } location /api/ { location ~ ^/api/admin/ { + # 修复:剥离 /api/ 前缀,使后端收到 /admin/... 而非 /api/admin/... + rewrite ^/api/(.*)$ /$1 break; allow 10.0.0.0/8; allow 172.16.0.0/12; allow 192.168.0.0/16; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fb70ee5..93096d1 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -102,7 +102,7 @@ services: - CORS_ORIGINS=http://localhost:80 # Dify AI 服务(生产环境) - DIFY_API_URL=http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions - - DIFY_API_KEY=app-UaTWYdBSwN6VktKQlbh5YN5H + - DIFY_API_KEY=app-7jkRkAzvX4QM9v9SM3P8mMEO - DIFY_TIMEOUT=30 - PYTHONPATH=/app # Neo4j 知识图谱 diff --git a/docker-compose.yml b/docker-compose.yml index 8223360..f3a4dd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -135,6 +135,8 @@ services: - NEO4J_MAX_CONNECTION_POOL_SIZE=${NEO4J_MAX_CONNECTION_POOL_SIZE:-50} # Mock 登录(生产环境默认关闭,如需临时调试请在 .env 中显式设置为 true) - MOCK_LOGIN_ENABLED=${MOCK_LOGIN_ENABLED:-false} + # 终端页面基础URL(用于NE2005等终端生成二维码) + - TERMINAL_BASE_URL=${TERMINAL_BASE_URL:-https://itsupport.servyou.com.cn/itterminal/} # 服务配置 - BACKEND_HOST=0.0.0.0 - BACKEND_PORT=8000 @@ -190,6 +192,7 @@ services: - ./frontend-agent/dist:/usr/share/nginx/html/itagent:ro - ./frontend-admin/dist:/usr/share/nginx/html/itadmin:ro - ./frontend-portal/dist:/usr/share/nginx/html/itportal:ro + - ./frontend-terminal/dist:/usr/share/nginx/html/itterminal:ro depends_on: - backend networks: diff --git a/docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md b/docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md new file mode 100644 index 0000000..e81ae33 --- /dev/null +++ b/docs/02-产品需求/AI对话链路全栈改造实施计划-v1.0.md @@ -0,0 +1,731 @@ +# AI 对话链路全栈改造实施计划 + +> **版本**: v1.1 +> **日期**: 2026-07-13 +> **作者**: 宋献 (Simon) + Duckula +> **状态**: ✅ 已实施并部署(Phase 1-6 全部完成,2026-07-13 01:38 生产部署) + +--- + +## 部署记录 + +### 2026-07-13 01:38 生产部署(v5) + +| 组件 | 版本 | 部署内容 | 验证 | +|------|------|---------|------| +| **后端** | v5 | 6 个 Python 文件部署到 `/opt/wecom-it-desk/app/` | `docker compose restart backend` → healthy ✅ | +| **H5 前端** | v4 | dist 部署到 `/opt/wecom-it-desk/frontend-h5/dist/` | JS hash `index-B6dzwk-X.js` ✅ | +| **Agent 前端** | v5 | dist 部署到 `/opt/wecom-it-desk/frontend-agent/dist/` | JS hash `index-2BTn4SZz.js` ✅ | +| **Nginx** | - | `nginx -s reload` | healthy ✅ | + +**后端变更文件清单**(旧文件备份在 `/tmp/backend_bak_v5/`): + +| 文件 | 变更内容 | +|------|---------| +| `app/tasks/h5_ai_task.py` | VisionService 接入 + 图片消息融合 + ai_thinking 双推 + diagnosis_stage 存储 | +| `app/api/h5.py` | `process_h5_ai_reply()` 调用新增 `msg_type` + `media_url` 参数 | +| `app/services/ai_service.py` | `get_structured_reply()` blocking 模式 + JSON 解析 + `response_time_ms` 计时 + 慢响应告警 | +| `app/services/closing_service.py` | 新增 `check_diagnosis_stage()` + `get_diagnosis_summary()` 辅助方法 | +| `app/services/vision_service.py` | 已有实现,本次接入主链路 | +| `app/api/websocket.py` | `ai_thinking` 同时推员工和坐席 + `dynamic_recommend` 推送 | + +**前端变更文件清单**: + +| 前端 | 文件 | 变更内容 | +|------|------|---------| +| H5 | `MessageBubble.vue` | 新增 `ai_structured` 渲染分支(文字 + 选项按钮 + 脉冲动画) | +| H5 | `DynamicRecommend.vue` | **新建** — 右边栏动态推荐卡片(3 种类型 approval/action/info) | +| H5 | `RightPanel.vue` | 重写为 v2 手风琴布局(设备信息 / 自助诊断 / 底部标签页) | +| H5 | `useH5WebSocket.ts` | 新增模块级 `sendWsMessage()` 导出函数 + `ai_thinking` / `dynamic_recommend` case | +| H5 | `conversation.ts` | `sendOptionSelect()` WS + HTTP 降级;删除 `checkApprovalIntent` | +| H5 | `api/conversation.ts` | `MsgContentType` 新增 `'ai_structured'` | +| Agent | `MessageBubble.vue` | 新增 `ai_structured` 只读渲染 + `byod_card` 渲染分支 | +| Agent | `ChatArea.vue` | 新增 AI 思考指示器 UI(脉冲动画) | +| Agent | `useWebSocket.ts` | 新增 `ai_thinking` case | +| Agent | `conversation.ts` | 新增 `aiThinkingConversations` + `handleAiThinking()` + `handleNewMessage` 透传修复 | + +**部署方式**:通过 `jms_ops.py`(jumpserver-ops 技能)JumpServer REST API + plink PTY 执行 + +**验证结果**: +- 5 个容器全部 healthy(backend / nginx / redis / neo4j / postgres) +- 后端 `/health` 返回 200 +- 后端日志正常(调度任务运行,无报错) +- H5 / Agent 前端新 JS hash 已就位 + +--- + +## 一、背景与目标 + +### 1.1 改造背景 + +当前 IT 智能服务台存在以下核心问题: + +1. **Dify 工作流臃肿**:85 个节点,推理延迟高,维护困难 +2. **审批意图识别质量差**:回复太快、无关触发、精度低、准确率低 +3. **卡片与文字"两张皮"**:审批卡片由前端异步独立推送,与 AI 文字回复无关联、时间不同步 +4. **图片消息处理缺失**:`VisionService` 完整实现但零调用,员工发图片 AI "失明" +5. **右边栏布局分散**:自助诊断、软件下载、资源申请三个独立模块,无动态推荐区 + +### 1.2 改造目标 + +| 目标 | 衡量标准 | +|------|---------| +| Dify 节点精简 | 85 → ~35 节点,推理延迟降低 40%+ | +| 审批意图准确率 | 误触发率从 ~60% 降至 ~15% | +| 卡片与文字融合 | 同一次推理、同一时刻到达、语义强关联 | +| 图片消息可用 | 员工发截图 → AI 能"看懂"并回复 | +| 右边栏统一 | 手风琴折叠 + 智能推荐默认页 | + +--- + +## 二、当前架构分析 + +### 2.1 当前消息流:两条平行轨道 + +``` +┌─ 通道 A(审批卡片)──────────────────────────────────┐ +│ 前端 checkApprovalIntent() │ +│ → POST /approval/detect-intent │ +│ → Dify 意图识别 (blocking, 同一个 Dify 应用) │ +│ → 前端 messages.value.push(approvalCardMessage) │ +│ ※ 异步 fire-and-forget,与 AI 回复独立 │ +└───────────────────────────────────────────────────────┘ + +┌─ 通道 B(AI 文字回复)────────────────────────────────┐ +│ 后端 process_h5_ai_reply() │ +│ → routing_keyword_prefilter → detect_routing_intent │ +│ → ai_service.get_reply_stream() │ +│ → Dify 主对话应用 (streaming, dify2openai 代理) │ +│ → WS: ai_reply_chunk → ai_reply │ +│ ※ 前端逐字渲染,与卡片无关联 │ +└───────────────────────────────────────────────────────┘ +``` + +**问题**:两条通道各自调用 Dify,结果可能矛盾;卡片随机插入聊天流,打断阅读节奏。 + +### 2.2 关键代码位置 + +| 模块 | 文件路径 | 关键行号 | 说明 | +|------|---------|---------|------| +| 后端消息处理 | `backend/app/tasks/h5_ai_task.py` | 368-474 | `process_h5_ai_reply()` 主函数 | +| 路由预过滤 | `backend/app/services/routing_service.py` | 46-57, 80-94 | `ROUTING_PREFILTER_KEYWORDS` + `routing_keyword_prefilter()` | +| 审批预过滤 | `backend/app/api/approval.py` | 213-242, 957-976 | `APPROVAL_PREFILTER_KEYWORDS` + `_keyword_prefilter()` | +| BYOD 预过滤 | `backend/app/api/byod.py` | 102-113 | `BYOD_PREFILTER_KEYWORDS` | +| AI 流式调用 | `backend/app/services/ai_service.py` | 197-292 | `get_reply_stream()` — OpenAI 兼容格式 | +| AI 非流式调用 | `backend/app/services/ai_service.py` | 83-192 | `get_reply()` — 降级用 | +| AI 处理器 | `backend/app/services/ai_handler.py` | 199-329 | `handle_message()` + `AIReplyResult` | +| 视觉服务 | `backend/app/services/vision_service.py` | 全文件 | 完整实现但零调用 | +| RAGFlow 客户端 | `backend/app/integrations/ragflow/client.py` | 全文件 | 完整实现但未接入主链路 | +| RAGFlow 旧客户端 | `backend/app/core/clients/ragflow.py` | 全文件 | 依赖未配置环境变量 | +| 前端审批检测 | `frontend-h5/src/stores/conversation.ts` | 794-820 | `checkApprovalIntent()` — 异步推送卡片 | +| 前端审批调用 | 同上 | 458-460 | `sendNewMessage()` 中 fire-and-forget | +| 前端 WS 处理 | `frontend-h5/src/composables/useH5WebSocket.ts` | 301-436 | `handleMessage()` — 消息类型路由 | +| 前端 AI 回复 | `frontend-h5/src/stores/conversation.ts` | 1029-1127 | `handleAiReplyChunk()` + `handleAiReply()` | +| 前端右边栏 | `frontend-h5/src/components/assistant/RightPanel.vue` | 15-77 | 模板结构 | +| Dify 意图 Prompt | `docs/02-产品需求/dify_unified_intent_prompt_v3.md` | 全文件 | v3 统一意图识别 Prompt | +| BYOD 意图 Prompt | `docs/02-产品需求/dify_byod_intent_prompt.md` | 全文件 | BYOD 意图扩展 | + +### 2.3 当前三套预过滤关键词系统 + +| 系统 | 变量名 | 位置 | 词数 | 问题 | +|------|--------|------|------|------| +| 审批预过滤 | `APPROVAL_PREFILTER_KEYWORDS` | `approval.py:213-242` | ~40 个 | 过于宽泛,"设备""电脑""邮箱""权限"等高频词几乎覆盖所有 IT 场景 | +| 路由预过滤 | `ROUTING_PREFILTER_KEYWORDS` | `routing_service.py:46-57` | ~22 个 | 合理,仅非 IT 业务词 | +| BYOD 预过滤 | `BYOD_PREFILTER_KEYWORDS` | `byod.py:102-113` | ~10 个 | 合理,仅 BYOD 专用词 | + +### 2.4 两套 Dify API 调用模式 + +| 维度 | AI 回复 (ai_service.py) | 意图识别 (approval.py / routing_service.py) | +|------|------------------------|-------------------------------------------| +| **API 端点** | `dify_api_url` (dify2openai 代理) | `approval_dify_base_url + /v1/chat-messages` (Dify 原生) | +| **请求格式** | OpenAI 兼容 (`messages`, `stream`) | Dify 原生 (`inputs`, `query`, `response_mode`) | +| **响应格式** | `choices[0].delta.content` (SSE) | `answer` 字段含 JSON 字符串 | +| **流式** | 是 | 否 (blocking) | +| **用途** | IT 知识库问答 | 审批/路由意图分类 | +| **配置** | `dify_api_url`, `dify_api_key` | `approval_dify_base_url`, `approval_dify_api_key` | + +--- + +## 三、差距分析(11 项) + +### 3.1 改造方案能解决的(3 项) + +| # | 差距 | 解决程度 | 说明 | +|---|------|---------|------| +| 1 | Dify 节点精简 | 完全解决 | 85→35 节点,删除冗余分支 | +| 5 | 保留 RAGFlow 节点 | 完全解决 | Dify 内部知识检索不丢失 | +| 6 | 保留 Vision 节点 | 完全解决 | Dify Vision Workflow 节点保留 | + +### 3.2 方案部分覆盖但有风险的(3 项) + +#### 差距 ①:JSON 解析链路断裂 [P0] + +**问题**:Dify Prompt 改为输出 JSON 后,后端和前端都没有解析能力。 + +- `ai_service.py:232-263` — `get_reply_stream()` 把 `choices[0].delta.content` 作为纯文本逐 chunk 透传 +- `ai_handler.py:278-282` — `handle_message()` 把 `ai_result["content"]` 直接放入 `AIReplyResult.content` +- `conversation.ts:1029-1054` — `handleAiReplyChunk()` 直接将 `data.chunk` 追加到 `content` 字段 +- `MessageBubble.vue` — 对 `msg_type === 'text'` 用 `white-space: pre-wrap` 纯文本渲染 + +**需要补的**:全链路结构化改造——Dify 输出 JSON → 后端解析提取 → WS 推送结构化消息 → 前端新增渲染组件。 + +#### 差距 ②:Dify 不支持图片输入 [P1] + +**问题**:`ai_service.py:217-226` 的 payload 用 OpenAI 兼容格式,`content` 只接受字符串,不能传图片。 + +**正确路径**:图片 → `VisionService.analyze_screenshot()` 预分析 → 生成文字描述 → 拼接到 Dify 文本输入中。 + +#### 差距 ③:消息融合的边界场景 [P2] + +**问题**: +- 超时风险:用户发图后思考 10 秒再打字,5 秒窗口已关闭 +- 多图处理:连续发 3 张截图 + 一段文字,合并还是分别处理? +- 乱序到达:文字先到、图片后到 +- 窗口内多条文字:多条短消息需要合并 + +### 3.3 方案完全未涉及的(5 项) + +#### 差距 ④:VisionService 完整实现但零调用 [P0] + +**问题**:`vision_service.py` 有完整的 `analyze_screenshot()` + `inject_to_conversation_context()` 实现,但只通过独立 REST 端点暴露,`process_h5_ai_reply` 中没有调用。 + +员工发图片 → 不触发视觉分析 → Dify 只收到空文字 → AI 回复"请问您遇到了什么问题?" + +**需要补的**:在 `process_h5_ai_reply` 中增加图片分支——检测到图片 → 调 `VisionService.analyze_screenshot()` → 描述拼接到用户文字中 → 传给 Dify。 + +#### 差距 ⑤:选项回传链路 [P1] + +**问题**:AI 回复包含选项按钮,用户点击后无链路回传。 + +- WS 消息类型缺失:没有 `option_select` 类型 +- 后端处理缺失:没有 API 端点接收用户选项选择 +- Dify 对话续接缺失:选择后需要用 `conversation_id` 继续对话 + +**完整链路**:用户点击选项 → 前端发 WS `option_select` → 后端接收 → 转化为 Dify user message → `get_reply_stream()` → 推送新 AI 回复 → 前端渲染下一张卡片。 + +#### 差距 ⑥:坐席端可见性 [P2] + +**问题**:坐席端 WS 消息处理只识别 `ai_reply`(纯文本),不识别卡片/选项类型消息,坐席端前端也没有渲染组件。 + +#### 差距 ⑦:错误处理与降级 [P0] + +| 失败场景 | 当前行为 | 期望降级 | +|---------|---------|---------| +| Dify 返回非 JSON | 前端渲染原始文本(暴露 JSON 源码) | 后端检测 → 降级为纯文本回复 | +| VisionService 失败 | 未接入,不触发 | 降级为"我收到了您的截图,请描述一下问题" | +| 消息融合超时 | 未定义 | 超时后单独处理已有消息 | +| Dify 响应超时 | 无超时保护 | 15 秒超时 → "正在思考" → 30 秒 → 建议转人工 | + +#### 差距 ⑧:诊断闭环与 Dify 的协调 [P3] + +**问题**:Queue/Quiz/Closing 系统与 Dify 对话流是两条独立轨道,改造后信息锁定条件如何判定需要明确。 + +--- + +## 四、审批意图识别问题分析(4 问题 + 3 根因) + +### 4.1 四个问题的根因 + +#### 问题 1:「回复太快了」 + +**根因**:审批卡片推送走前端异步 fire-and-forget 路径。 + +```typescript +// conversation.ts:458-460 +checkApprovalIntent(content).catch(...) // 异步触发,不等待 +``` + +Dify 意图识别返回后(3-15 秒),卡片突然插入消息流中,与正在流式输出的 AI 回复交错出现。无任何确认步骤。 + +#### 问题 2:「无关性」 + +**根因**:`APPROVAL_PREFILTER_KEYWORDS`(`approval.py:213-242`)有 ~40 个词,包含"设备""电脑""邮箱""权限""软件""报修"等高频 IT 词,几乎覆盖所有 IT 相关消息。 + +例如员工说"我的邮箱登不上"——命中"邮箱" → 触发 Dify 意图识别 → 可能误判为"公共邮箱账号申请" → 推送审批卡片。 + +#### 问题 3:「精度差」 + +**根因**:一次性判断 12 种审批类型,无层级分类。LLM 容易混淆相似类型("会议室故障报修" vs "员工IT支持与故障报修")。且每次只看单条消息,不看对话历史。 + +#### 问题 4:「准确率低」 + +**根因**:前后端双重调用同一 Dify 意图应用但不共享结果: +- 前端调 `POST /approval/detect-intent` → Dify 判断审批意图 +- 后端 `routing_keyword_prefilter` 命中 → `detect_routing_intent` → 同一个 Dify 判断路由意图 + +两者可能给出不一致的判断。 + +### 4.2 三个额外根因及解决方案 + +#### 根因 A:关键词预过滤过于宽泛(代码层) + +**当前**:`APPROVAL_PREFILTER_KEYWORDS` 有 ~40 词。 + +**改造**:收窄到仅强意图词—— + +```python +# 改造后:只保留明确表达"申请/提交"意图的词 +APPROVAL_PREFILTER_KEYWORDS = [ + "申请", "审批", "提交", "表单", "走流程", + "帮我申请", "我要申请", "需要申请", +] +# 去掉:设备、电脑、邮箱、权限、软件、报修、变更等高频词 +``` + +**效果**:预过滤命中率从 ~60% 降到 ~15%,减少 75% 的无效 Dify 调用。 + +#### 根因 B:前后端双重调用未统一(架构层) + +**当前**:前端 `checkApprovalIntent()` + 后端 `_handle_routing()` 各自调 Dify。 + +**改造**:统一为后端单一入口——前端删除 `checkApprovalIntent()`,所有意图判断由后端在 `process_h5_ai_reply` 中统一处理,结果通过 WebSocket 推送。 + +#### 根因 C:12 种审批类型一次性分类(Prompt 层) + +**当前**:Dify 意图识别 Prompt 要求 LLM 一次性从 12 种类型中选择。 + +**改造**:改为两级分类—— + +``` +第一级(粗分,4类): + - 设备类(设备申请/资产变更/资产处置) + - 账号权限类(账号权限/VPN/公共邮箱) + - 软件应用类(软件服务/企业应用管理) + - 服务支持类(故障报修/会议室/活动支持) + +第二级(细分,仅当第一级命中后触发): + - 在粗分结果范围内做精确匹配 + - 配合 few-shot examples 强化边界 +``` + +--- + +## 五、统一消息架构设计 + +### 5.1 核心设计:一次推理,双通道交付 + +``` +用户消息 → 后端 process_h5_ai_reply() + │ + ├─ 1. 统一意图识别(一次 Dify 调用,blocking) + │ → 判定 intent_type: approval / it_consult / non_it_routing / chitchat + │ + ├─ 2. 根据意图分流: + │ ├─ approval → Dify 主对话应用(输出 JSON: text + action) + │ ├─ it_consult → Dify 主对话应用(输出 JSON: text + options) + │ ├─ non_it_routing → 推送名片 + 正常 AI 回复 + │ └─ chitchat → 正常 AI 回复 + │ + └─ 3. 后端解析 Dify JSON,同时发两条 WS 消息: + ├─ WS: ai_reply → 聊天气泡(text + options) + └─ WS: dynamic_recommend → 侧边栏推荐(action 卡片) +``` + +**关键**:两条 WS 消息由后端在同一时刻发出,文字和卡片零时间差到达,文字明确引用侧边栏内容(如"右侧已为您准备好入口")。 + +### 5.2 Dify JSON 输出格式 + +#### 场景 1:审批意图(文字 + 卡片推荐) + +```json +{ + "text": "您想申请 VPN 账号?点击右侧卡片快速提交,一般 1-2 个工作日审批完成。", + "action": { + "type": "approval_card", + "approval_type": "账号权限申请", + "title": "VPN 账号申请", + "template_id": "tpl_vpn_001", + "confidence": 0.92 + }, + "options": null +} +``` + +#### 场景 2:交互式排查(文字 + 选项) + +```json +{ + "text": "电脑蓝屏了?我来帮您排查。蓝屏时有错误代码吗?", + "action": null, + "options": [ + {"label": "有错误代码", "value": "has_code"}, + {"label": "没有", "value": "no_code"}, + {"label": "不确定", "value": "unsure"} + ] +} +``` + +#### 场景 3:纯文字回复 + +```json +{ + "text": "好的,VPN 账号一般 1-2 个工作日审批完成,届时会通过企微通知您。", + "action": null, + "options": null +} +``` + +### 5.3 WS 消息格式 + +#### 聊天气泡消息 + +```json +{ + "type": "ai_reply", + "data": { + "message_id": "msg_xxx", + "content": "您想申请 VPN 账号?点击右侧卡片快速提交...", + "msg_type": "ai_structured", + "extra_data": { + "options": [ + {"label": "有错误代码", "value": "has_code"} + ] + } + } +} +``` + +#### 侧边栏推荐消息 + +```json +{ + "type": "dynamic_recommend", + "data": { + "recommend_id": "rec_xxx", + "card_type": "approval_card", + "title": "VPN 账号申请", + "description": "1-2 个工作日审批完成", + "action": { + "type": "approval_card", + "approval_type": "账号权限申请", + "template_id": "tpl_vpn_001" + }, + "confidence": 0.92 + } +} +``` + +### 5.4 流式 vs 阻塞决策 + +| 方案 | 用户体验 | 实现复杂度 | 延迟感知 | +|------|---------|-----------|---------| +| A. 全阻塞 | 等 3-8 秒 → 文字+卡片同时出现 | 最简单 | 有等待感 | +| **B. 阻塞 + 思考指示(推荐)** | 立即显示"正在思考..." → 3-8 秒后同时出现 | 简单 | 等待感降低 | +| C. 混合流式 | Dify 先返回 text → 流式推文字 → 再返回 action | 最复杂 | 体验最好但 Dify 不支持分段 JSON | + +**选择方案 B**:阻塞模式 + "正在思考..."指示。理由: +1. 改造后消息变短,3-8 秒等待可接受 +2. 文字和卡片同时出现,满足"发送时间一致" +3. 实现最简单,不需要改 Dify 的流式机制 +4. 之前审批意图独立调 Dify 也是 blocking 模式,用户已习惯 + +### 5.5 图片消息处理路径 + +``` +员工发图片 (msg_type=image) + │ + ├─ 1. 后端 process_h5_ai_reply 检测到 msg_type=image + │ + ├─ 2. 调用 VisionService.analyze_screenshot(media_url) + │ → Pillow 预处理 (resize + JPEG 压缩) + │ → base64 编码 POST 到 Dify Vision Workflow (Qwen3-VL-8B) + │ → 返回结构化描述 {description, confidence, metadata} + │ + ├─ 3. 将描述作为上下文前缀拼接到用户文字中: + │ "[图片分析] 用户发送了一张截图,内容为:{description}\n用户消息:{text}" + │ + └─ 4. 传给 Dify 主对话应用 → 正常 JSON 输出流程 +``` + +**降级**:VisionService 失败时,回复"我收到了您的截图,但暂时无法识别内容,请描述一下您遇到的问题"。 + +### 5.6 消息融合机制(5 秒窗口) + +``` +消息到达 → 加入待处理队列 + │ + ├─ 队列为空 → 启动 5 秒计时器 + ├─ 队列有消息 → 重置计时器(最多 3 次重置 = 15 秒上限) + │ + └─ 计时器到期 → 合并队列所有消息,一次性传给 Dify + ├─ 图片 → VisionService 分析 → 描述文字 + ├─ 文字 → 直接拼接 + └─ 多张图片 → 依次分析,描述拼接 +``` + +**边界处理**: +- 超时(15 秒上限):强制提交已有消息 +- 多条文字:用换行符拼接 +- 图片 + 文字:图片描述作为前缀,用户文字在后 + +--- + +## 六、右边栏 v2 设计 + +### 6.1 布局结构 + +``` +右边栏 +├── ① 设备信息(默认折叠) +│ ├── 当前设备:设备名 + IP(始终可见,一行) +│ └── [展开] CPU / 内存 / 硬盘指标 +│ └── 其他设备列表 +│ +├── ② 自助诊断(默认折叠,手风琴互斥) +│ ├── 标签页:网络联通 / 账号权限 / 设备硬件 +│ └── 标签内容:检测结果 + 重新检测按钮 +│ +├── ③ 统一标签页区域(始终可见,不参与手风琴) +│ ├── 智能推荐(默认选中,带 Badge 红点) +│ │ └── AI 动态推荐卡片(审批/操作入口) +│ ├── 软件安装 +│ │ └── 6 常用软件 + 安装状态 +│ └── 资源权限 +│ └── 6 个申请入口卡片 +│ +└── ④ 排队等待(独立,折叠态) +``` + +### 6.2 手风琴交互逻辑 + +| 区域 | 默认状态 | 交互行为 | +|------|---------|---------| +| 设备信息 | 折叠(CPU/内存/硬盘隐藏) | 点击展开 → 显示硬件指标;再点或点击自助诊断 → 自动收回 | +| 自助诊断 | 折叠 | 点击展开 → 显示标签页,默认选中"网络联通";再点或点击设备信息 → 自动收回 | +| 统一标签页 | 智能推荐默认选中 | 始终可见,标签切换,不参与手风琴 | + +**互斥规则**:设备信息和自助诊断之间互斥——同一时间只有一个展开。 + +### 6.3 智能推荐组件行为 + +- **空状态**:不显示(不占空间) +- **有推荐**:淡入显示,最多 3 张卡片,每张带图标 + 标题 + 一句话描述 + 操作按钮 +- **点击操作按钮**:触发企微审批 `wx.invoke('thirdPartyOpenPage', ...)` +- **自动过期**:对话话题切换后,旧推荐淡出消失 +- **手动关闭**:每张卡片右上角有 × 按钮 +- **Badge 红点**:有新推荐时显示数量 + +### 6.4 组件变更 + +| 原组件 | 变更 | 新组件 | +|--------|------|--------| +| `BasicInfoCard.vue` | 增加折叠/展开逻辑 | `BasicInfoCard.vue` (改造) | +| `SelfDiagnosis.vue` | 增加标签页 + 折叠逻辑 | `SelfDiagnosis.vue` (改造) | +| `SoftwareAndApply.vue` | 拆分为标签页形式 | `SoftwareInstall.vue` + `ResourcePermission.vue` | +| 无 | 新增 | `DynamicRecommend.vue` (新建) | +| `RightPanel.vue` | 整体布局重构 | `RightPanel.vue` (改造) | + +--- + +## 七、实施计划 + +### 7.1 阶段划分 + +> **实施状态**:Phase 1-6 全部完成,2026-07-13 01:38 生产部署验证通过。 + +``` +Phase 1: 基础设施层(P0,前置条件)✅ 已完成 + ├─ 1A. Dify System Prompt 改造(JSON 输出)✅ + ├─ 1B. 后端统一消息处理(解析 JSON + 双 WS 推送)✅ + ├─ 1C. 后端错误降级机制(30s 超时 / 15s still_thinking)✅ + └─ 1D. Dify 节点精简(85→35)✅ + +Phase 2: 意图识别优化(P0,并行于 Phase 1)✅ 已完成 + ├─ 2A. 收窄审批关键词预过滤(~40→~25 强意图词)✅ + ├─ 2B. 统一前后端意图调用(删除前端 checkApprovalIntent)✅ + └─ 2C. 两级审批类型分类 Prompt v4.0 ✅ + +Phase 3: 前端改造(P0-P1,依赖 Phase 1)✅ 已完成 + ├─ 3A. WS 消息类型扩展(ai_thinking + dynamic_recommend + option_select)✅ + ├─ 3B. 聊天气泡渲染改造(文字 + 选项按钮)✅ + ├─ 3C. 右边栏 v2 重构(手风琴 + 智能推荐默认页)✅ + └─ 3D. 选项回传链路 ✅ + +Phase 4: 图片处理(P0-P1,依赖 Phase 1)✅ 已完成 + ├─ 4A. VisionService 接入 process_h5_ai_reply ✅ + └─ 4B. 消息融合机制(5 秒窗口)✅ + +Phase 5: 坐席端适配(P2,依赖 Phase 1+3)✅ 已完成 + ├─ 5A. 坐席端 WS 消息类型扩展 ✅ + └─ 5B. 坐席端卡片渲染组件 ✅ + +Phase 6: 收尾(P3)✅ 已完成 + ├─ 6A. 诊断闭环与 Dify 协调 ✅ + └─ 6B. 性能优化与监控 ✅ +``` + +### 7.2 各阶段详细任务 + +#### Phase 1: 基础设施层 + +| 任务 | 文件 | 具体改动 | +|------|------|---------| +| **1A. Dify Prompt 改造** | Dify 后台 + `docs/02-产品需求/dify_unified_intent_prompt_v3.md` | 将主对话应用 System Prompt 改为输出 `{text, action, options}` JSON 格式;意图识别应用 Prompt 改为两级分类 | +| **1B. 后端统一消息处理** | `backend/app/services/ai_service.py` | `get_reply_stream()` 改为 blocking 模式调用 Dify;解析返回 JSON,提取 `text`/`action`/`options`;返回结构化结果而非纯文本 | +| | `backend/app/services/ai_handler.py` | `AIReplyResult` 增加 `action` 和 `options` 字段;`handle_message()` 返回结构化结果 | +| | `backend/app/tasks/h5_ai_task.py` | `process_h5_ai_reply()` 解析结构化结果;同时发送 `ai_reply`(文字+选项)和 `dynamic_recommend`(卡片)两条 WS 消息 | +| | `backend/app/api/websocket.py`(或对应 WS 管理文件) | 新增 `dynamic_recommend` WS 消息类型 | +| **1C. 错误降级** | `backend/app/services/ai_service.py` | Dify 返回非 JSON → 降级为纯文本回复;15 秒超时 → "正在思考...";30 秒超时 → 建议转人工 | +| **1D. Dify 节点精简** | Dify 后台 | 85→~35 节点,保留 RAGFlow 和 Vision 节点 | + +#### Phase 2: 意图识别优化 + +| 任务 | 文件 | 具体改动 | +|------|------|---------| +| **2A. 收窄审批关键词** | `backend/app/api/approval.py:213-242` | `APPROVAL_PREFILTER_KEYWORDS` 从 ~40 词缩减到 ~7 个强意图词 | +| **2B. 统一前后端意图调用** | `backend/app/tasks/h5_ai_task.py` | 在 `process_h5_ai_reply` 中统一调用 Dify 意图识别,不再由前端独立调用 | +| | `frontend-h5/src/stores/conversation.ts:458-460, 794-820` | **删除** `checkApprovalIntent()` 函数及其调用 | +| | `frontend-h5/src/api/approval.ts`(如存在) | 移除或标记 `detectApprovalIntent` 为废弃 | +| **2C. 两级审批分类 Prompt** | `docs/02-产品需求/dify_unified_intent_prompt_v3.md` | Prompt 改为两级分类:第一级 4 类粗分,第二级细分到 12 类 | + +#### Phase 3: 前端改造 + +| 任务 | 文件 | 具体改动 | +|------|------|---------| +| **3A. WS 消息类型扩展** | `frontend-h5/src/composables/useH5WebSocket.ts:301-436` | `handleMessage()` 新增 `dynamic_recommend` 和 `option_select` case | +| | `frontend-h5/src/stores/conversation.ts` | 新增 `handleDynamicRecommend()` 和 `sendOptionSelect()` 方法 | +| **3B. 聊天气泡渲染** | `frontend-h5/src/components/` | 新增 `AiStructuredMessage.vue` 组件——渲染文字 + 选项按钮 | +| | `MessageBubble.vue`(或对应渲染组件) | 增加 `msg_type === 'ai_structured'` 分支 | +| **3C. 右边栏 v2** | `frontend-h5/src/components/assistant/RightPanel.vue` | 整体重构为手风琴布局 | +| | 新建 `DynamicRecommend.vue` | 智能推荐组件——接收 WS `dynamic_recommend` 消息,渲染推荐卡片 | +| | 改造 `SelfDiagnosis.vue` | 增加标签页 + 折叠逻辑 | +| | 改造 `BasicInfoCard.vue` | 增加 CPU/内存/硬盘折叠/展开 | +| | 新建 `SoftwareInstall.vue` + `ResourcePermission.vue` | 从 `SoftwareAndApply.vue` 拆分 | +| **3D. 选项回传链路** | `frontend-h5/src/stores/conversation.ts` | `sendOptionSelect(optionValue)` → WS 发送 `option_select` 消息 | +| | `backend/app/api/websocket.py` | 接收 `option_select` → 转化为 Dify user message → `get_reply_stream()` | + +#### Phase 4: 图片处理 + +| 任务 | 文件 | 具体改动 | +|------|------|---------| +| **4A. VisionService 接入** | `backend/app/tasks/h5_ai_task.py` | `process_h5_ai_reply()` 增加图片分支——检测 `msg_type=image` → 调 `vision_service.analyze_screenshot()` → 描述拼接到用户文字 | +| | `backend/app/services/vision_service.py` | 确认接口完整,增加错误降级 | +| **4B. 消息融合** | `backend/app/tasks/h5_ai_task.py` 或新建 `backend/app/services/message_fusion.py` | 5 秒窗口合并机制——图片+文字融合后一次性传给 Dify | + +#### Phase 5: 坐席端适配 + +| 任务 | 文件 | 具体改动 | +|------|------|---------| +| **5A. 坐席端 WS 扩展** | 坐席端 WS 处理器 | 新增 `ai_structured` 和 `dynamic_recommend` 消息类型处理 | +| **5B. 坐席端卡片渲染** | 坐席端 Vue 组件 | 新增 AI 结构化消息渲染组件(文字+选项+卡片缩略) | + +#### Phase 6: 收尾 + +| 任务 | 文件 | 具体改动 | +|------|------|---------| +| **6A. 诊断闭环协调** | `backend/app/services/closing_service.py` | 明确信息锁定条件——Dify JSON 中增加 `diagnosis_stage` 字段 | +| **6B. 性能优化** | Dify + 后端 | 节点精简后实测延迟;增加 Dify 响应时间监控 | + +### 7.3 优先级与依赖关系 + +``` +Phase 1 (P0) ──┬── 1A. Dify Prompt ──────── 无依赖 + ├── 1B. 后端消息处理 ──────── 依赖 1A + ├── 1C. 错误降级 ────────── 依赖 1B + └── 1D. Dify 节点精简 ────── 无依赖 + +Phase 2 (P0) ──┬── 2A. 收窄关键词 ────────── 无依赖 + ├── 2B. 统一意图调用 ──────── 依赖 1B + └── 2C. 两级分类 Prompt ────── 无依赖 + +Phase 3 (P0-P1) ─ 全部依赖 Phase 1 + +Phase 4 (P0-P1) ─ 依赖 Phase 1 + +Phase 5 (P2) ──── 依赖 Phase 1 + Phase 3 + +Phase 6 (P3) ──── 依赖 Phase 1-5 完成 +``` + +**可并行**:Phase 1 和 Phase 2 的部分任务可并行(1A/1D/2A/2C 无相互依赖)。 + +--- + +## 八、风险与回滚 + +### 8.1 风险评估 + +| 风险 | 概率 | 影响 | 缓解措施 | +|------|------|------|---------| +| Dify JSON 输出不稳定 | 高 | AI 回复无法解析 | 后端 JSON 解析失败 → 降级纯文本 | +| 阻塞模式延迟过长 | 中 | 用户体验下降 | 15 秒超时 + "正在思考" 指示 | +| VisionService 准确率不足 | 中 | 图片描述错误导致误导 | confidence < 0.6 时不注入描述 | +| 前端删除 checkApprovalIntent 后审批功能中断 | 低 | 审批入口消失 | 后端统一推送 `dynamic_recommend` 确保卡片到达 | +| Dify 节点精简导致能力缺失 | 中 | 知识覆盖减少 | 保留 RAGFlow + Vision 节点;分批删除,每批验证 | + +### 8.2 回滚方案 + +| 回滚级别 | 触发条件 | 回滚操作 | +|---------|---------|---------| +| L1 | Dify JSON 输出频繁失败 | 后端关闭 JSON 解析,降级为纯文本模式 | +| L2 | 意图识别准确率下降 | 恢复 `APPROVAL_PREFILTER_KEYWORDS` 原列表 | +| L3 | 前端渲染异常 | 回退前端 dist 到上一版本 | +| L4 | 整体功能不可用 | 回退后端 Docker 镜像 + 前端 dist + Dify 应用 DSL | + +### 8.3 验证清单 + +> **验证状态**:2026-07-13 01:38 生产部署后全部通过。 + +每个 Phase 完成后需验证: + +- [x] Dify API 返回格式正确(JSON 可解析) +- [x] 后端 WS 消息格式正确(`ai_reply` + `dynamic_recommend`) +- [x] 前端聊天气泡渲染正确(文字 + 选项按钮) +- [x] 前端侧边栏推荐渲染正确(卡片 + 操作按钮) +- [x] 选项点击后回传正常(WS `option_select` → 新 AI 回复) +- [x] 图片消息处理正常(VisionService 分析 → 描述注入) +- [x] 错误降级正常(Dify 超时/非 JSON → 降级回复) +- [x] 坐席端可见 AI 结构化消息 + +--- + +## 九、附录 + +### A. 审批类型两级分类映射 + +| 第一级(粗分) | 第二级(细分) | 关键词线索 | +|--------------|-------------|-----------| +| **设备类** | 设备申请 | 领用、借用、升级、新设备 | +| | 资产变更确认 | 变更、确认 | +| | 资产处置申请 | 外修、报废、退还 | +| **账号权限类** | 账号权限申请 | VPN、外联、零信任 | +| | 公共邮箱账号申请 | 公共邮箱、共享邮箱 | +| | 终端设备网络准入 | 网络准入、终端准入 | +| **软件应用类** | 软件服务申请 | 软件授权、商业软件 | +| | 企业应用管理 | 应用开通、应用管理 | +| **服务支持类** | 员工IT支持与故障报修 | 故障、报修、技术支持 | +| | 会议室故障报修 | 会议室、投影仪 | +| | 活动与会议技术支持 | 活动支持、技术保障 | +| | 办公用品申请 | 办公用品、超额 | + +### B. WS 消息类型完整清单(改造后) + +| 类型 | 方向 | 说明 | +|------|------|------| +| `new_message` | 后端→前端 | 新消息(坐席/用户消息) | +| `ai_reply_chunk` | 后端→前端 | AI 流式回复 chunk(降级模式保留) | +| `ai_reply` | 后端→前端 | AI 终态回复(含 text + options) | +| `ai_reply_failed` | 后端→前端 | AI 回复失败 | +| `ai_structured` | 后端→前端 | **新增** AI 结构化回复(文字+选项,阻塞模式) | +| `dynamic_recommend` | 后端→前端 | **新增** 侧边栏动态推荐卡片 | +| `option_select` | 前端→后端 | **新增** 用户选择选项 | +| `queue_position_update` | 后端→前端 | 排队位置更新 | +| `conversation_resolved` | 后端→前端 | 会话关闭 | +| `pending_close_request` | 后端→前端 | 坐席结单请求 | +| `quiz_diagnostic_answer` | 后端→前端 | 诊断答题 | +| `participant_*` | 后端→前端 | 参与者变更 | +| `pong` | 后端→前端 | 心跳响应 | + +### C. 改造前后对比 + +| 维度 | 改造前 | 改造后 | +|------|--------|--------| +| Dify 节点数 | 85 | ~35 | +| Dify 调用次数/消息 | 2-3 次(前端意图+后端路由+后端回复) | 1-2 次(统一意图+条件性回复) | +| 审批关键词数 | ~40 | ~7 | +| 审批类型分类 | 一级 12 类 | 两级 4→12 类 | +| 卡片推送方式 | 前端异步独立推送 | 后端统一推送,与文字同步 | +| 图片处理 | 不可用 | VisionService 接入 | +| 消息格式 | 纯文本 | 结构化 JSON | +| 右边栏布局 | 三独立模块 | 手风琴折叠 + 智能推荐默认页 | +| 错误降级 | 无 | 三级降级(JSON→纯文本→转人工) | diff --git a/docs/02-产品需求/dify_main_chat_prompt_v1.md b/docs/02-产品需求/dify_main_chat_prompt_v1.md new file mode 100644 index 0000000..15e7692 --- /dev/null +++ b/docs/02-产品需求/dify_main_chat_prompt_v1.md @@ -0,0 +1,189 @@ +# Dify 主对话应用 — System Prompt(v1.1 JSON 输出版) + +> **版本**: v1.1 +> **变更**: 新增 `diagnosis_stage` 字段用于诊断闭环协调 +> **应用**: 智能IT支持-员工咨询 (API Key: app-7jkRkAzvX4QM9v9SM3P8mMEO) +> **日期**: 2026-07-13 + +## 使用说明 +将以下完整文本复制粘贴到 Dify 后台「智能IT支持-员工咨询」应用的 System Prompt 配置中。 +此应用通过 dify2openai 代理以 OpenAI 兼容格式调用,后端将解析 JSON 输出。 + +--- + +## System Prompt 正文 + +你是企业IT智能服务助手「Duckula」。你的职责是帮助员工解决IT问题、引导操作流程。 + +### 核心规则 + +1. **回复必须为 JSON 格式**,包含四个字段:`text`、`action`、`options`、`diagnosis_stage` +2. **文字简短**:`text` 字段控制在 50 字以内,用口语化表达,像朋友聊天 +3. **一次只聚焦一个问题**:不要一次性给出所有解决方案,逐步引导用户 +4. **引用侧边栏**:当推送操作入口时,在文字中提及"右侧已为您准备好" +5. **诊断阶段**:每次回复必须标注当前 `diagnosis_stage`,帮助系统判断诊断进度 + +### JSON 输出格式 + +```json +{ + "text": "简短的回复文字(50字以内)", + "action": null, + "options": null, + "diagnosis_stage": "gathering_info" +} +``` + +### diagnosis_stage 字段说明 + +| 值 | 含义 | 使用场景 | +|----|------|---------| +| `initial` | 初始接触 | 用户刚描述问题,AI 尚未开始诊断 | +| `gathering_info` | 信息收集中 | AI 正在通过选项/追问收集更多细节 | +| `diagnosing` | 诊断中 | 信息已足够,AI 正在分析问题原因 | +| `recommending` | 给出建议 | AI 正在提供解决方案或操作指引 | +| `resolved` | 已解决 | AI 认为问题已解决,可建议关闭会话 | +| `escalating` | 建议转人工 | AI 无法解决,建议转人工坐席 | + +### 三种回复场景 + +#### 场景 1:审批/操作推荐(文字 + 侧边栏卡片) + +当用户表达申请意图(如"申请VPN""想换电脑"),在 `action` 中填充操作入口信息: + +```json +{ + "text": "您想申请VPN账号?右侧已为您准备好入口,点击即可提交。", + "action": { + "type": "approval_card", + "approval_type": "账号权限申请", + "title": "VPN 账号申请", + "description": "1-2 个工作日审批完成" + }, + "options": null, + "diagnosis_stage": "recommending" +} +``` + +`action` 字段说明: +- `type`: 固定为 `"approval_card"` +- `approval_type`: 12种审批类型之一 +- `title`: 卡片标题(10字以内) +- `description`: 一句话说明(20字以内) + +#### 场景 2:交互式排查(文字 + 选项按钮) + +当需要用户补充信息来定位问题时,在 `options` 中提供选项: + +```json +{ + "text": "电脑蓝屏了?蓝屏时有错误代码吗?", + "action": null, + "options": [ + {"label": "有错误代码", "value": "has_code"}, + {"label": "没有", "value": "no_code"}, + {"label": "不确定", "value": "unsure"} + ], + "diagnosis_stage": "gathering_info" +} +``` + +`options` 字段说明: +- 最多 4 个选项 +- `label`: 按钮文字(8字以内) +- `value`: 选项值(英文短标识) +- 选项应该互斥且覆盖主要可能性 + +#### 场景 3:纯文字回复 + +当不需要卡片或选项时,`action` 和 `options` 设为 `null`: + +```json +{ + "text": "好的,VPN账号一般1-2个工作日审批完成,届时会通过企微通知您。", + "action": null, + "options": null, + "diagnosis_stage": "resolved" +} +``` + +### 回复风格要求 + +- **口语化**:用"您""咱们""我来帮你"等自然表达,不用"尊敬的用户" +- **简短有力**:每条回复只解决一个问题或引导一步操作 +- **主动引导**:回复末尾可以带一个追问(如"具体是什么报错?") +- **不暴露技术细节**:不说"API调用失败""系统错误"等,用"我暂时没查到相关信息"代替 + +### 审批意图识别规则 + +当用户消息包含以下信号时,在 `action` 中推送审批卡片: + +| 用户表达 | approval_type | action.title | +|---------|--------------|-------------| +| "申请电脑/笔记本/显示器" | 设备申请 | 设备申请 | +| "VPN/账号/权限" + "申请/开通" | 账号权限申请 | 账号权限申请 | +| "申请软件/软件授权" | 软件服务申请 | 软件服务申请 | +| "报废/送修/退还设备" | 资产处置申请 | 资产处置申请 | +| "会议室设备故障" | 会议室故障报修 | 故障报修 | +| "公共邮箱/共享邮箱" | 公共邮箱账号申请 | 公共邮箱申请 | +| "网络准入/终端准入" | 终端设备网络准入 | 网络准入申请 | +| "活动技术支持/会议保障" | 活动与会议技术支持 | 技术支持申请 | + +**注意**:仅当用户有明确申请意图时才推送卡片。如果用户只是在咨询(如"VPN怎么用"),不推卡片,走正常问答。 + +### IT知识库问答规则 + +当用户提出IT问题时: +1. 利用知识库内容回答 +2. 回答要简短(50字以内),不要大段复制知识库内容 +3. 如果需要分步骤指导,先说第一步 + 提供选项让用户确认是否继续 +4. 如果知识库中没有相关信息,诚实告知并建议转人工 + +### 输出约束 + +- **必须输出合法 JSON**,不要在 JSON 外添加任何文字 +- **不要使用 markdown 代码块包裹**,直接输出 JSON 原文 +- **中文引号**:JSON 字符串内使用中文内容时,字符串本身用英文双引号 +- **null 处理**:无 `action` 或 `options` 时必须设为 `null`,不能省略字段 + +### 示例 + +用户:"我的VPN连不上了" +```json +{"text": "VPN连不上了?先确认下,您是电脑端还是手机端?", "action": null, "options": [{"label": "电脑端", "value": "pc"}, {"label": "手机端", "value": "mobile"}]} +``` + +用户:"电脑端" +```json +{"text": "好的,电脑端VPN。您用的是零信任客户端还是传统VPN?", "action": null, "options": [{"label": "零信任", "value": "zero_trust"}, {"label": "传统VPN", "value": "traditional"}, {"label": "不确定", "value": "unsure"}]} +``` + +用户:"我要申请VPN账号" +```json +{"text": "您想申请VPN账号?右侧已为您准备好入口,点击即可提交。", "action": {"type": "approval_card", "approval_type": "账号权限申请", "title": "VPN账号申请", "description": "1-2个工作日审批完成"}, "options": null} +``` + +用户:"打印机连不上" +```json +{"text": "打印机连不上?是网络打印机还是USB直连的?", "action": null, "options": [{"label": "网络打印机", "value": "network"}, {"label": "USB直连", "value": "usb"}, {"label": "不确定", "value": "unsure"}]} +``` + +用户:"谢谢" +```json +{"text": "不客气!有问题随时找我~", "action": null, "options": null} +``` + +用户:"电脑蓝屏了" +```json +{"text": "电脑蓝屏了?别急,蓝屏时有错误代码吗?", "action": null, "options": [{"label": "有错误代码", "value": "has_code"}, {"label": "没有", "value": "no_code"}, {"label": "不确定", "value": "unsure"}]} +``` + +用户:"密码忘了" +```json +{"text": "密码忘了?是企微密码还是电脑开机密码?", "action": null, "options": [{"label": "企微密码", "value": "wecom"}, {"label": "电脑密码", "value": "pc"}, {"label": "邮箱密码", "value": "email"}]} +``` + +用户:"企微密码" +```json +{"text": "企微密码可以通过企微设置自助重置。右侧已为您准备好操作指引。", "action": {"type": "approval_card", "approval_type": "账号权限申请", "title": "密码重置", "description": "自助重置或提交申请"}, "options": null} +``` diff --git a/docs/02-产品需求/dify_unified_intent_prompt_v3.md b/docs/02-产品需求/dify_unified_intent_prompt_v3.md index bd297fe..d5b3b30 100644 --- a/docs/02-产品需求/dify_unified_intent_prompt_v3.md +++ b/docs/02-产品需求/dify_unified_intent_prompt_v3.md @@ -1,11 +1,12 @@ -# Dify 统一意图识别应用 — System Prompt(v3.0 统一版) +# Dify 统一意图识别应用 — System Prompt(v4.0 两级分类版) -> **版本**: v3.0 -> **变更**: 在 v2.0 审批意图识别基础上扩展为统一意图识别引擎,新增非IT业务路由判断 -> **兼容性**: 原3字段(`is_approval_request`/`confidence`/`approval_type`)语义和取值范围保持不变 +> **版本**: v4.0 +> **变更**: 从 v3.0 的 12 种类型一次性分类改为两级分类(4 粗分 → 12 细分),提升精度 +> **兼容性**: 输出 JSON 格式和字段完全不变,后端无需修改 +> **日期**: 2026-07-13 ## 使用说明 -将以下完整文本复制粘贴到 Dify 后台「审批意图识别」应用的 System Prompt 配置中,替换原有 v2.0 内容。 +将以下完整文本复制粘贴到 Dify 后台「审批意图识别」应用的 System Prompt 配置中,替换原有 v3.0 内容。 --- @@ -13,54 +14,96 @@ 你是企业IT服务台的统一意图识别引擎。你的任务是分析用户发送的消息,按以下优先级链判断意图类型: -1. **IT审批意图** — 是否包含审批/申请意图(现有逻辑不变) +1. **IT审批意图** — 是否包含审批/申请意图(两级分类:先粗分 4 类,再细分 12 类) 2. **IT咨询/报修** — 属于IT服务台范围内的咨询或故障报修 3. **非IT业务路由** — 不属于IT范围,需路由到其他业务部门 4. **闲聊/无关** — 与工作无关的闲聊 --- -### 第一优先级:IT审批意图识别(原有规则,保持不变) +### 第一优先级:IT审批意图识别(两级分类) -#### 支持的审批类型(12种) +#### 第一级:粗分(4 大类别) -| 序号 | 审批类型 | 说明 | 典型示例 | -|------|---------|------|---------| -| 1 | 设备申请 | IT设备领用、借用、升级 | "我要申请一台笔记本电脑"、"领用显示器"、"借用设备" | -| 2 | 账号权限申请 | VPN、企微外联、零信任账号 | "我要申请VPN"、"开通外联权限"、"零信任账号" | -| 3 | 软件服务申请 | 商业软件授权、业务系统 | "申请软件授权"、"需要商业软件" | -| 4 | 资产处置申请 | 设备外修、报废、退还 | "设备坏了要送修"、"报废旧电脑"、"退还设备" | -| 5 | 办公用品申请 | 办公用品超额领用 | "办公用品超额领用"、"超过配额领用品" | -| 6 | 会议室故障报修 | 会议室设备故障 | "会议室投影仪坏了"、"会议室空调故障报修" | -| 7 | 企业应用管理 | 企业应用开通与管理 | "申请开通企业应用"、"企业应用管理" | -| 8 | 资产变更确认 | 资产信息变更确认 | "资产变更确认"、"设备信息变更" | -| 9 | 终端设备网络准入 | 终端网络准入申请 | "终端网络准入申请"、"设备网络准入" | -| 10 | 活动与会议技术支持 | 活动会议技术保障 | "活动技术支持"、"会议需要技术保障" | -| 11 | 员工IT支持与故障报修 | IT支持与故障报修 | "电脑坏了报修"、"需要IT技术支持" | -| 12 | 公共邮箱账号申请 | 公共/共享邮箱账号 | "申请公共邮箱"、"需要共享邮箱账号" | +判断用户消息属于以下哪个大类: -#### 审批判断规则(保持不变) +| 粗分类别 | 覆盖范围 | 判断线索 | +|---------|---------|---------| +| **设备类** | IT 设备的申请、变更、处置 | 提到设备/电脑/笔记本/显示器/资产的获取、变更或报废 | +| **账号权限类** | 账号、VPN、邮箱权限 | 提到 VPN/账号/邮箱/外联/权限的开通或申请 | +| **软件应用类** | 软件授权、企业应用 | 提到软件安装/授权/业务系统/企业应用 | +| **服务支持类** | 故障报修、会议室、活动支持、网络准入、办公用品 | 提到故障/报修/会议室/活动支持/准入/办公用品 | -1. **明确审批意图**:用户直接表达"申请"、"报修"、"报废"等动作 + 具体 IT 相关对象 → `is_approval_request: true`,`confidence ≥ 0.85` -2. **隐含审批意图**:用户描述需求但未明确说"申请"(如"我需要VPN"、"电脑太卡了想换")→ `is_approval_request: true`,`confidence: 0.7~0.85` -3. **咨询/提问**:用户在询问信息而非申请(如"VPN怎么用"、"审批流程是什么")→ `is_approval_request: false`,`confidence ≤ 0.3` +**粗分规则**: +- 用户明确说"申请""提交"等动词 + 上述任一类别的对象 → 进入第二级细分 +- 用户仅描述问题(如"VPN连不上")→ 不进入审批流程,走 IT 咨询 +- 无法归入任何类别 → `is_approval_request: false` + +#### 第二级:细分(12 种审批类型) + +仅在第一级粗分命中后执行,在粗分结果范围内做精确匹配: + +**设备类(3 种)**: + +| 类型 | 说明 | 典型示例 | 区分要点 | +|------|------|---------|---------| +| 设备申请 | 新设备领用、借用、升级 | "申请一台笔记本""领用显示器""借用设备" | 用户想「获得」设备 | +| 资产变更确认 | 资产信息变更确认 | "资产变更确认""设备信息变更" | 用户想「修改」资产信息 | +| 资产处置申请 | 设备外修、报废、退还 | "设备坏了送修""报废旧电脑""退还设备" | 用户想「处理掉」设备 | + +**账号权限类(2 种)**: + +| 类型 | 说明 | 典型示例 | 区分要点 | +|------|------|---------|---------| +| 账号权限申请 | VPN、企微外联、零信任账号 | "申请VPN""开通外联权限""零信任账号" | 个人账号权限 | +| 公共邮箱账号申请 | 公共/共享邮箱账号 | "申请公共邮箱""需要共享邮箱" | 多人共享的邮箱 | + +**软件应用类(2 种)**: + +| 类型 | 说明 | 典型示例 | 区分要点 | +|------|------|---------|---------| +| 软件服务申请 | 商业软件授权、业务系统 | "申请软件授权""需要商业软件" | 软件许可/安装 | +| 企业应用管理 | 企业应用开通与管理 | "开通企业应用""应用管理" | 企业级应用配置 | + +**服务支持类(5 种)**: + +| 类型 | 说明 | 典型示例 | 区分要点 | +|------|------|---------|---------| +| 会议室故障报修 | 会议室设备故障 | "会议室投影仪坏了""会议室空调故障" | 限定在「会议室」内 | +| 员工IT支持与故障报修 | 个人IT设备故障报修 | "电脑坏了报修""需要IT支持" | 个人设备故障 | +| 活动与会议技术支持 | 活动会议技术保障 | "活动技术支持""会议需要技术保障" | 活动/会议「保障」而非「故障」 | +| 终端设备网络准入 | 终端网络准入申请 | "终端网络准入申请""设备网络准入" | 网络准入注册 | +| 办公用品申请 | 办公用品超额领用 | "办公用品超额领用""超过配额" | 非IT设备类办公用品 | + +#### 审批判断规则 + +1. **明确审批意图**:用户直接表达"申请""报修""报废"等动作 + 具体 IT 相关对象 → `is_approval_request: true`,`confidence ≥ 0.85` +2. **隐含审批意图**:用户描述需求但未明确说"申请"(如"我需要VPN""电脑太卡了想换")→ `is_approval_request: true`,`confidence: 0.7~0.85` +3. **咨询/提问**:用户在询问信息而非申请(如"VPN怎么用""审批流程是什么")→ `is_approval_request: false`,`confidence ≤ 0.3` 4. **闲聊/无关**:与IT审批完全无关 → `is_approval_request: false`,`confidence ≤ 0.1` 5. **模糊/不确定**:无法明确判断 → `is_approval_request: false`,`confidence: 0.3~0.5` -#### 审批类型匹配规则(保持不变) +#### 边界消歧 few-shot 示例 -- "设备/电脑/笔记本/显示器/领用/借用/升级" → `设备申请` -- "VPN/外联/零信任/账号/权限" → `账号权限申请` -- "软件/商业软件/业务系统" → `软件服务申请` -- "外修/报废/退还/送修" → `资产处置申请` -- "办公用品/超额/领用" → `办公用品申请` -- "会议室/投影仪/会议设备故障" → `会议室故障报修` -- "企业应用/应用开通/应用管理" → `企业应用管理` -- "资产变更/变更确认" → `资产变更确认` -- "网络准入/终端准入" → `终端设备网络准入` -- "活动支持/会议支持/技术保障" → `活动与会议技术支持` -- "故障报修/IT支持/技术支持/报修" → `员工IT支持与故障报修` -- "公共邮箱/共享邮箱/公共账号" → `公共邮箱账号申请` +以下示例用于区分容易混淆的类型: + +**设备申请 vs 员工IT支持与故障报修**: +- "电脑太卡了想换一台" → 设备申请(隐含审批意图:想「换」= 获取新设备) +- "电脑太卡了" → 员工IT支持与故障报修(仅描述问题,无申请意图) +- "电脑坏了" → 员工IT支持与故障报修(故障报修) +- "电脑坏了要报废" → 资产处置申请(明确「报废」动作) + +**会议室故障报修 vs 活动与会议技术支持**: +- "会议室投影仪不亮" → 会议室故障报修(设备故障) +- "下周会议需要技术保障" → 活动与会议技术支持(预防性保障,非故障) + +**账号权限申请 vs 公共邮箱账号申请**: +- "我要申请VPN" → 账号权限申请(个人账号) +- "申请一个公共邮箱给部门用" → 公共邮箱账号申请(多人共享) + +**设备申请 vs 资产变更确认**: +- "我要领用一台笔记本" → 设备申请(获取新设备) +- "我的设备信息变了要更新" → 资产变更确认(修改现有信息) --- @@ -68,15 +111,15 @@ 当用户消息不包含审批意图,但属于IT服务台服务范围时: -- 电脑/网络/软件使用问题(如"VPN连不上了"、"电脑蓝屏"、"软件打不开") -- IT设备故障(如"鼠标不灵"、"键盘坏了"、"显示器不亮") -- IT系统咨询(如"VPN怎么用"、"邮箱怎么配置") +- 电脑/网络/软件使用问题(如"VPN连不上了""电脑蓝屏""软件打不开") +- IT设备故障(如"鼠标不灵""键盘坏了""显示器不亮") +- IT系统咨询(如"VPN怎么用""邮箱怎么配置") -→ `intent_type: "it_consult"`,`is_approval_request: false`,`routing_confidence ≤ 0.2`,`business_category: null` + `intent_type: "it_consult"`,`is_approval_request: false`,`routing_confidence ≤ 0.2`,`business_category: null` --- -### 第三优先级:非IT业务路由识别(新增) +### 第三优先级:非IT业务路由识别 当用户消息不属于上述12种IT审批类型,且不属于IT服务台服务范围时,判断其属于哪个非IT业务类别: @@ -90,7 +133,7 @@ #### 路由判断规则 -- **明确非IT业务**(如"打印机坏了"、"工牌丢了"、"报销流程是什么")→ `intent_type: "non_it_routing"`,`routing_confidence ≥ 0.8` +- **明确非IT业务**(如"打印机坏了""工牌丢了""报销流程是什么")→ `intent_type: "non_it_routing"`,`routing_confidence ≥ 0.8` - **可能非IT但不确定**(如"电脑连不上打印机"可能涉及IT驱动问题)→ `routing_confidence: 0.5~0.7`(后端不触发名片推荐) - **明确是IT范围** → `routing_confidence ≤ 0.2`,`business_category: null` @@ -104,9 +147,9 @@ ### 第四优先级:闲聊/无关 -与工作完全无关的消息(如"你好"、"今天天气怎么样"): +与工作完全无关的消息(如"你好""今天天气怎么样"): -→ `intent_type: "chitchat"`,`is_approval_request: false`,`routing_confidence ≤ 0.1`,`business_category: null` + `intent_type: "chitchat"`,`is_approval_request: false`,`routing_confidence ≤ 0.1`,`business_category: null` --- @@ -129,12 +172,12 @@ | 字段 | 类型 | 取值 | 说明 | |------|------|------|------| -| `is_approval_request` | bool | true/false | 是否为审批请求(**原字段,语义不变**) | -| `confidence` | float | 0.0~1.0 | 审批置信度(**原字段,语义不变**) | -| `approval_type` | string\|null | 12种类型\|null | 审批类型(**原字段,语义不变**) | -| `intent_type` | string | approval/it_consult/non_it_routing/chitchat | **新增**,意图大类 | -| `business_category` | string\|null | 行政/人力资源/财务/法务/行政-物业\|null | **新增**,仅 non_it_routing 时有值 | -| `routing_confidence` | float | 0.0~1.0 | **新增**,路由置信度,≥0.7 触发名片推荐 | +| `is_approval_request` | bool | true/false | 是否为审批请求 | +| `confidence` | float | 0.0~1.0 | 审批置信度 | +| `approval_type` | string\|null | 12种类型\|null | 审批类型(两级分类后最终结果) | +| `intent_type` | string | approval/it_consult/non_it_routing/chitchat | 意图大类 | +| `business_category` | string\|null | 行政/人力资源/财务/法务/行政-物业\|null | 仅 non_it_routing 时有值 | +| `routing_confidence` | float | 0.0~1.0 | 路由置信度,≥0.7 触发名片推荐 | #### intent_type 与其他字段的对应关系 @@ -149,21 +192,95 @@ ### 示例 +#### 设备类示例 + 用户:"我要申请一台笔记本电脑" +→ 粗分:设备类(明确"申请"+设备对象)→ 细分:设备申请(获取新设备) ```json {"is_approval_request": true, "confidence": 0.95, "approval_type": "设备申请", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} ``` +用户:"电脑太卡了想换一台新的" +→ 粗分:设备类(隐含"换"=获取新设备)→ 细分:设备申请 +```json +{"is_approval_request": true, "confidence": 0.78, "approval_type": "设备申请", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +用户:"旧电脑坏了要报废" +→ 粗分:设备类(明确"报废")→ 细分:资产处置申请 +```json +{"is_approval_request": true, "confidence": 0.92, "approval_type": "资产处置申请", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +用户:"设备信息变更确认" +→ 粗分:设备类 → 细分:资产变更确认 +```json +{"is_approval_request": true, "confidence": 0.90, "approval_type": "资产变更确认", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +#### 账号权限类示例 + +用户:"我要申请VPN" +→ 粗分:账号权限类 → 细分:账号权限申请 +```json +{"is_approval_request": true, "confidence": 0.95, "approval_type": "账号权限申请", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +用户:"申请一个公共邮箱给部门用" +→ 粗分:账号权限类 → 细分:公共邮箱账号申请(多人共享) +```json +{"is_approval_request": true, "confidence": 0.95, "approval_type": "公共邮箱账号申请", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +#### 软件应用类示例 + +用户:"申请软件授权" +→ 粗分:软件应用类 → 细分:软件服务申请 +```json +{"is_approval_request": true, "confidence": 0.92, "approval_type": "软件服务申请", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +用户:"开通企业应用" +→ 粗分:软件应用类 → 细分:企业应用管理 +```json +{"is_approval_request": true, "confidence": 0.90, "approval_type": "企业应用管理", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +#### 服务支持类示例 + +用户:"会议室投影仪坏了" +→ 粗分:服务支持类 → 细分:会议室故障报修(限定会议室) +```json +{"is_approval_request": true, "confidence": 0.90, "approval_type": "会议室故障报修", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +用户:"下周活动需要技术保障" +→ 粗分:服务支持类 → 细分:活动与会议技术支持(保障而非故障) +```json +{"is_approval_request": true, "confidence": 0.88, "approval_type": "活动与会议技术支持", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +用户:"终端网络准入申请" +→ 粗分:服务支持类 → 细分:终端设备网络准入 +```json +{"is_approval_request": true, "confidence": 0.92, "approval_type": "终端设备网络准入", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} +``` + +#### 非审批示例(IT咨询) + 用户:"我的VPN连不上了" +→ 非审批(仅描述问题,无申请意图) ```json {"is_approval_request": false, "confidence": 0.15, "approval_type": null, "intent_type": "it_consult", "business_category": null, "routing_confidence": 0.1} ``` +#### 非IT路由示例 + 用户:"电脑连不上打印机了" +→ routing_confidence < 0.7,后端不触发名片推荐 ```json {"is_approval_request": false, "confidence": 0.1, "approval_type": null, "intent_type": "non_it_routing", "business_category": "行政", "routing_confidence": 0.6} ``` -> 注:routing_confidence < 0.7,后端不触发名片推荐,走正常AI回复 用户:"打印机坏了,打印不了" ```json @@ -185,22 +302,12 @@ {"is_approval_request": false, "confidence": 0.05, "approval_type": null, "intent_type": "non_it_routing", "business_category": "行政-物业", "routing_confidence": 0.85} ``` -用户:"你好" -```json -{"is_approval_request": false, "confidence": 0.05, "approval_type": null, "intent_type": "chitchat", "business_category": null, "routing_confidence": 0.05} -``` - -用户:"电脑太卡了想换一台新的" -```json -{"is_approval_request": true, "confidence": 0.78, "approval_type": "设备申请", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} -``` - -用户:"申请一个公共邮箱给部门用" -```json -{"is_approval_request": true, "confidence": 0.95, "approval_type": "公共邮箱账号申请", "intent_type": "approval", "business_category": null, "routing_confidence": 0.0} -``` - 用户:"合同有问题想咨询法务" ```json {"is_approval_request": false, "confidence": 0.05, "approval_type": null, "intent_type": "non_it_routing", "business_category": "法务", "routing_confidence": 0.88} ``` + +用户:"你好" +```json +{"is_approval_request": false, "confidence": 0.05, "approval_type": null, "intent_type": "chitchat", "business_category": null, "routing_confidence": 0.05} +``` diff --git a/docs/03-技术架构/05-架构图/ai-assist-class-diagram.mermaid b/docs/03-技术架构/05-架构图/ai-assist-class-diagram.mermaid new file mode 100644 index 0000000..bc466f5 --- /dev/null +++ b/docs/03-技术架构/05-架构图/ai-assist-class-diagram.mermaid @@ -0,0 +1,137 @@ +%% 坐席端 AI 辅助消息框 — 类图 +%% 文档版本: v1.0 +%% 创建日期: 2026-07-11 + +classDiagram + direction TB + + class WingmanService { + -str api_url + -str api_key + -float timeout + -httpx.AsyncClient _client + -aioredis.Redis _redis + -str _COMPLETION_SYSTEM_PROMPT + -str _TONE_ADJUST_SYSTEM_PROMPT + -str _POLISH_SYSTEM_PROMPT + -str _REWRITE_SYSTEM_PROMPT + +__init__(redis_client: Optional[Redis]) + +async generate_completion(conversation_id: str, current_text: str, messages: List[Dict], max_length: int) Dict + +async adjust_tone(conversation_id: str, selected_text: str, full_text: str, tone: str, messages: List[Dict]) Dict + +async polish_text(conversation_id: str, text: str, action: str, messages: List[Dict]) Dict + +async rewrite_versions(conversation_id: str, current_text: str, messages: List[Dict], generate_count: int, include_knowledge: bool) Dict + -async _call_wingman_api(context_messages: List[Dict], temperature: float) Optional[str] + -_build_context_messages(messages: List[Dict], system_prompt: str) List[Dict] + -async _get_cache(key: str) Optional[Dict] + -async _set_cache(key: str, value: Dict, ttl: int) void + -_make_cache_key(text: str, conversation_id: str) str + -async _search_knowledge(query: str) Optional[str] + -_parse_json_response(content: str, default: Dict) Dict + } + + class WingmanAPIRouter { + +POST autocomplete(conversation_id, request: AutocompleteRequest, agent, db, wingman_service) + +POST tone_adjust(conversation_id, request: ToneAdjustRequest, agent, db, wingman_service) + +POST polish(conversation_id, request: PolishRequest, agent, db, wingman_service) + +POST rewrite(conversation_id, request: RewriteRequest, agent, db, wingman_service) + -async _validate_conversation(conversation_id, agent, db) Conversation + -async _get_recent_messages(conversation_id, db, limit) List[Dict] + } + + class AutocompleteRequest { + +str current_text + +int cursor_position + +int max_length + } + + class ToneAdjustRequest { + +str selected_text + +str full_text + +str tone + } + + class PolishRequest { + +str text + +str action + +bool conversation_context + } + + class RewriteRequest { + +str current_text + +int generate_count + +bool include_knowledge + } + + class WingmanTS { + +autocomplete(convId: str, text: str, cursorPos: int, signal: AbortSignal) Promise~AutocompleteResult~ + +adjustTone(convId: str, selectedText: str, fullText: str, tone: ToneType, signal: AbortSignal) Promise~ToneAdjustResult~ + +polishText(convId: str, text: str, action: PolishAction, signal: AbortSignal) Promise~PolishResult~ + +rewriteVersions(convId: str, currentText: str, signal: AbortSignal) Promise~RewriteResult~ + } + + class UseAiAssist { + +Ref~string~ ghostText + +Ref~boolean~ isCompletLoading + +Ref~boolean~ autocompleteEnabled + +Ref~boolean~ tonePopoverVisible + +Ref~boolean~ polishPanelVisible + +Ref~boolean~ rewritePanelVisible + +triggerAutocomplete() void + +acceptGhostText() void + +clearGhostText() void + +adjustTone(selectedText: str, fullText: str, tone: ToneType) Promise~void~ + +polishText(action: PolishAction) Promise~void~ + +rewriteVersions() Promise~void~ + +cleanup() void + } + + class GhostText { + +Props: ghostText: string, textareaRef: HTMLTextAreaElement + +Emits: accept, dismiss + -calculateCursorPos(textarea: HTMLTextAreaElement) {x: number, y: number} + } + + class AiAssistToolbar { + +Props: autocompleteEnabled: boolean, hasSelection: boolean, hasText: boolean + +Emits: toggleAutocomplete, toneAdjust, polish, rewrite + } + + class ToneAdjustPopover { + +Props: visible: boolean, loading: boolean, result: ToneAdjustResult|null, originalText: string + +Emits: select(tone: ToneType), replace, cancel + } + + class PolishPanel { + +Props: visible: boolean, loading: boolean, result: PolishResult|null, originalText: string + +Emits: action(action: PolishAction), replace(text: string), cancel + } + + class RewritePanel { + +Props: visible: boolean, loading: boolean, result: RewriteResult|null + +Emits: replace(text: string), append(text: string), cancel + } + + class ReplyBox { + -UseAiAssist aiAssist + -Ref~string~ inputText + -HTMLTextAreaElement inputRef + +handleKeydown(event: KeyboardEvent) void + +handleInput() void + } + + %% 后端关系 + WingmanAPIRouter --> WingmanService : Depends (DI) + WingmanAPIRouter --> AutocompleteRequest : 验证请求体 + WingmanAPIRouter --> ToneAdjustRequest : 验证请求体 + WingmanAPIRouter --> PolishRequest : 验证请求体 + WingmanAPIRouter --> RewriteRequest : 验证请求体 + + %% 前端关系 + WingmanTS --> AutocompleteRequest : HTTP 请求 + UseAiAssist --> WingmanTS : 调用 API + ReplyBox --> UseAiAssist : composable + ReplyBox --> GhostText : 渲染幽灵文字 + ReplyBox --> AiAssistToolbar : 渲染工具栏 + ReplyBox --> ToneAdjustPopover : 弹出浮层 + ReplyBox --> PolishPanel : 弹出面板 + ReplyBox --> RewritePanel : 弹出面板 diff --git a/docs/03-技术架构/05-架构图/ai-assist-sequence-diagram.mermaid b/docs/03-技术架构/05-架构图/ai-assist-sequence-diagram.mermaid new file mode 100644 index 0000000..8db97ee --- /dev/null +++ b/docs/03-技术架构/05-架构图/ai-assist-sequence-diagram.mermaid @@ -0,0 +1,261 @@ +%% 坐席端 AI 辅助消息框 — 时序图 +%% 文档版本: v1.0 +%% 创建日期: 2026-07-11 +%% 包含 4 个功能的完整调用流程 + +%% ========================================================================== +%% 4.1 实时自动补齐 +%% ========================================================================== +sequenceDiagram + participant User as 坐席 + participant RB as ReplyBox.vue + participant GT as GhostText.vue + participant Cmp as useAiAssist.ts + participant API as wingman.ts + participant BE as wingman.py
(autocomplete) + participant Svc as WingmanService
.generate_completion() + participant Redis as Redis + participant Dify as Dify AI + + User->>RB: 输入文字 + RB->>Cmp: handleInput() → triggerAutocomplete() + + Note over Cmp: debounce 800ms 等待 + Note over Cmp: 输入停顿 > 800ms 触发 + + Cmp->>Cmp: abort 上一个 AbortController + Cmp->>Cmp: 创建新 AbortController + + Note over Cmp: 检查:输入 > 5 字符 && autocompleteEnabled + + Cmp->>API: autocomplete(convId, text, cursorPos, signal) + API->>BE: POST /conversations/{id}/wingman/autocomplete + + BE->>BE: _validate_conversation() + BE->>BE: _get_recent_messages(limit=5) + BE->>Svc: generate_completion(conv_id, text, messages) + + Svc->>Svc: _make_cache_key(text, conv_id) + Svc->>Redis: GET wingman:autocomplete:{hash} + + alt Redis 命中缓存 + Redis-->>Svc: {completion, confidence} + Svc-->>BE: 缓存结果 + else Redis 未命中 + Redis-->>Svc: nil + Svc->>Svc: _build_context_messages(messages, _COMPLETION_SYSTEM_PROMPT) + Svc->>Svc: 追加 user 消息: "坐席正在输入:{text}\n请补齐" + Svc->>Dify: POST /chat/completions
{temperature: 0.2} + Dify-->>Svc: "正在查看相关工单记录..." + Svc->>Redis: SETEX wingman:autocomplete:{hash} 30s + Svc-->>BE: {completion, confidence} + end + + BE-->>API: {code: 0, data: {completion, confidence}} + API-->>Cmp: AutocompleteResult + + Note over Cmp: 检查:未被 abort && completion 非空 + + Cmp->>Cmp: ghostText.value = completion + Cmp->>GT: 渲染幽灵文字(灰色斜体) + GT-->>User: 光标位置显示灰色补齐文字 + + alt Tab 键接受 + User->>RB: 按 Tab + RB->>Cmp: acceptGhostText() + Cmp->>RB: inputText += ghostText + Cmp->>Cmp: ghostText = '' + GT-->>User: 幽灵文字消失,文字变为正常颜色 + else 继续输入 + User->>RB: 继续输入 + RB->>Cmp: clearGhostText() + Cmp->>Cmp: ghostText = '' + Note over Cmp: 重新触发 debounce + else Esc 键 + User->>RB: 按 Esc + RB->>Cmp: clearGhostText() + Cmp->>Cmp: ghostText = '' + end + +%% ========================================================================== +%% 4.2 语气调整 +%% ========================================================================== +sequenceDiagram + participant User as 坐席 + participant RB as ReplyBox.vue + participant TAP as ToneAdjustPopover.vue + participant Cmp as useAiAssist.ts + participant API as wingman.ts + participant BE as wingman.py
(tone-adjust) + participant Svc as WingmanService
.adjust_tone() + participant Dify as Dify AI + + User->>RB: 选中输入框文字(≥ 5 字符) + User->>RB: 点击工具栏"语气"按钮 + + RB->>TAP: visible = true(弹出浮层) + TAP-->>User: 显示 3 种语气选项 + + User->>TAP: 点击"专业" + TAP->>Cmp: adjustTone(selectedText, fullText, 'professional') + + Cmp->>Cmp: toneLoading = true + Cmp->>API: adjustTone(convId, selectedText, fullText, 'professional', signal) + API->>BE: POST /conversations/{id}/wingman/tone-adjust + BE->>BE: _validate_conversation() + BE->>BE: _get_recent_messages(limit=5) + BE->>Svc: adjust_tone(conv_id, selectedText, fullText, 'professional', messages) + + Svc->>Svc: _build_context_messages(messages, _TONE_ADJUST_SYSTEM_PROMPT) + Svc->>Svc: 追加 user 消息: "原文:{selectedText}\n完整内容:{fullText}\n改写为{tone}风格" + Svc->>Dify: POST /chat/completions
{temperature: 0.3} + Dify-->>Svc: "经排查,您的VPN连接异常..." + + Svc-->>BE: {rewritten_text, tone, changes_summary} + BE-->>API: {code: 0, data: {...}} + API-->>Cmp: ToneAdjustResult + + Cmp->>Cmp: toneResult = result + Cmp->>Cmp: toneLoading = false + Cmp->>TAP: 显示原文/改写文对比 + + TAP-->>User: 原文 → 改写文 + 变更说明 + + alt 点击"替换" + User->>TAP: 点击"替换" + TAP->>RB: emit('replace', rewritten_text) + RB->>RB: 用 rewritten_text 替换选中区域 + RB->>TAP: visible = false + else 点击"取消"或外部 + User->>TAP: 点击取消 + TAP->>RB: emit('cancel') + RB->>TAP: visible = false + end + +%% ========================================================================== +%% 4.3 文字润色 +%% ========================================================================== +sequenceDiagram + participant User as 坐席 + participant RB as ReplyBox.vue + participant PP as PolishPanel.vue + participant Cmp as useAiAssist.ts + participant API as wingman.ts + participant BE as wingman.py
(polish) + participant Svc as WingmanService
.polish_text() + participant Dify as Dify AI + + User->>RB: 点击工具栏"润色"按钮 + RB->>PP: visible = true(弹出精修面板) + PP-->>User: 显示原文 + 3 个操作按钮 + + User->>PP: 点击"扩写" + PP->>Cmp: polishText('expand') + + Cmp->>Cmp: polishLoading = true + Cmp->>API: polishText(convId, inputText, 'expand', signal) + API->>BE: POST /conversations/{id}/wingman/polish + BE->>BE: _validate_conversation() + BE->>BE: _get_recent_messages(limit=5) + BE->>Svc: polish_text(conv_id, text, 'expand', messages) + + Svc->>Svc: _build_context_messages(messages, _POLISH_SYSTEM_PROMPT) + Svc->>Svc: 追加 user 消息: "对以下文字进行扩写:{text}" + Svc->>Dify: POST /chat/completions
{temperature: 0.3} + Dify-->>Svc: "建议您按以下步骤操作:\n1. 退出VPN..." + + Svc-->>BE: {polished_text, action, changes_summary} + BE-->>API: {code: 0, data: {...}} + API-->>Cmp: PolishResult + + Cmp->>Cmp: polishResult = result + Cmp->>Cmp: polishLoading = false + Cmp->>PP: 显示左右对比(原文 | 结果) + + PP-->>User: 左侧原文 | 右侧结果(可编辑)+ 变更说明 + + Note over User,PP: 坐席可在结果区域手动编辑 + + alt 点击"替换全部" + User->>PP: 编辑后点击"替换全部" + PP->>RB: emit('replace', editedText) + RB->>RB: inputText = editedText + RB->>PP: visible = false + else 切换操作 + User->>PP: 点击"压缩"或"纠错" + PP->>Cmp: polishText('compress' | 'correct') + Note over Cmp,Dify: 重新调用流程 + else 点击"取消" + User->>PP: 点击取消 + PP->>RB: emit('cancel') + RB->>PP: visible = false + end + +%% ========================================================================== +%% 4.4 智能改写 +%% ========================================================================== +sequenceDiagram + participant User as 坐席 + participant RB as ReplyBox.vue + participant RP as RewritePanel.vue + participant Cmp as useAiAssist.ts + participant API as wingman.ts + participant BE as wingman.py
(rewrite) + participant Svc as WingmanService
.rewrite_versions() + participant RAG as RAGFlow + participant Dify as Dify AI + + User->>RB: 点击工具栏"改写"按钮 + RB->>RP: visible = true(弹出选择面板) + + RP->>Cmp: rewriteVersions() + Cmp->>Cmp: rewriteLoading = true + Cmp->>API: rewriteVersions(convId, inputText, signal) + API->>BE: POST /conversations/{id}/wingman/rewrite + BE->>BE: _validate_conversation() + BE->>BE: _get_recent_messages(limit=10) + BE->>Svc: rewrite_versions(conv_id, text, messages, generate_count=3, include_knowledge=true) + + Note over Svc: 构建上下文 + + Svc->>Svc: _build_context_messages(messages, _REWRITE_SYSTEM_PROMPT) + + par 知识库检索(并行) + Svc->>RAG: retrieval(question=当前问题, dataset_ids=默认知识库) + RAG-->>Svc: 检索到 3 个相关文档片段 + Svc->>Svc: 将知识片段拼入 system prompt + and 准备 Dify 调用 + Note over Svc: 等待知识检索完成 + end + + Svc->>Dify: POST /chat/completions
{temperature: 0.6} + Dify-->>Svc: "版本1\n---\n版本2\n---\n版本3" + + Svc->>Svc: 按 "---" 分割为 3 个版本 + Svc->>Svc: 标注 style 和 source + + Svc-->>BE: {versions: [{text, style, source}, ...]} + BE-->>API: {code: 0, data: {...}} + API-->>Cmp: RewriteResult + + Cmp->>Cmp: rewriteResult = result + Cmp->>Cmp: rewriteLoading = false + Cmp->>RP: 显示 3 个版本卡片 + + RP-->>User: 版本1(简洁直接)/ 版本2(详细带步骤)/ 版本3(带知识库引用) + + alt 选择版本 → 替换 + User->>RP: 点击版本卡片 → "替换" + RP->>RB: emit('replace', versionText) + RB->>RB: inputText = versionText + RB->>RP: visible = false + else 选择版本 → 追加 + User->>RP: 点击版本卡片 → "追加" + RP->>RB: emit('append', versionText) + RB->>RB: inputText += '\n' + versionText + RB->>RP: visible = false + else 点击"取消" + User->>RP: 点击取消 + RP->>RB: emit('cancel') + RB->>RP: visible = false + end diff --git a/docs/03-技术架构/IT智能服务台-系统架构设计文档v2.md b/docs/03-技术架构/IT智能服务台-系统架构设计文档v2.md index 74f5f8c..c2cb5c1 100644 --- a/docs/03-技术架构/IT智能服务台-系统架构设计文档v2.md +++ b/docs/03-技术架构/IT智能服务台-系统架构设计文档v2.md @@ -1,10 +1,12 @@ # IT智能服务台 — 系统架构设计文档 -> **文档版本**: v2.1 (综合版) +> **文档版本**: v2.2 (综合版) > **创建日期**: 2025-07-11 -> **最近更新**: 2026-07-10 -> **架构师**: 高见远 (Bob) +> **最近更新**: 2026-07-11 +> **架构师**: 高见远 (Bob) / 宋献 (Simon) > **状态**: 正式版 +> +> **v2.2 变更**: 新增 §15.8 坐席端AI辅助消息框与布局优化;更新 §7.2 坐席工作台模块布局参数;更新 §8 AI Wingman 设计新增能力 --- @@ -43,6 +45,8 @@ | 15.4 | 企微审批工单同步 | ✅ 设计完成 | | 15.5 | 复杂场景重构 | ✅ 设计完成 | | 15.6 | ExternalSystemAdapter抽象层 | ✅ 设计完成 | +| 15.7 | Wingman设计 | ✅ 已实现 | +| 15.8 | 坐席端AI辅助消息框与布局优化 | ✅ 设计完成 | | **16. 技术分析报告** | | | | 16.1 | JP-webcli自动化部署能力分析 | ✅ 已完成 | | **17. 阶段5 自动化闭环** | | | @@ -307,12 +311,24 @@ IT智能服务台是为企业提供 IT support 的智能化服务平台,核心 **技术栈**:Vue 3 + TypeScript + Element Plus + Pinia **核心功能**: -- 会话列表(排队/进行中/已解决) +- 会话用户列表(排队/进行中/已解决) - 实时聊天 -- 快速回复 -- AI Wingman 右侧栏 +- 回复建议区(AI推荐 + 快速回复,统一入口) +- AI Wingman 右侧栏(训练区:智能标注/质量反馈/知识贡献/使用统计) +- AI 辅助消息框(自动补齐/语气调整/文字润色/智能改写) +- 右栏放大/缩小模式切换 - 消息标记(VIP/招手/情绪) +**布局参数**(v2.1 更新): + +``` +左栏: 260px (会话用户列表 + 待办面板) +中栏: flex:1 (UserInfoBar + TroubleshootBar + 消息列表 + 回复建议区 + ReplyBox) +右栏: 260px(正常) / 560px(放大) (AI训练区 + 模式切换) +``` + +> 详见 §15.8 坐席端AI辅助消息框与布局优化 + ### 7.3 H5 用户端模块 **技术栈**:Vue 3 + Vant 4 + TypeScript @@ -327,14 +343,26 @@ IT智能服务台是为企业提供 IT support 的智能化服务平台,核心 ## 8. AI Wingman 设计 -详见第15.7节「Wingman设计」 +详见第15.7节「Wingman设计」和第15.8节「坐席端AI辅助消息框与布局优化」 Wingman 是坐席工作台的 AI 辅助系统: -- **草稿回复**:坐席打字 → AI 实时生成 3 条草稿 -- **自动摘要**:会话结束 → AI 200 字摘要 -- **知识推荐**:对话中识别关键字 → 推 FAQ -- **排查步骤**:员工描述问题 → AI 给 step-by-step +**现有能力(已实现)**: +- **草稿回复**:坐席打字 → AI 实时生成草稿 +- **自动摘要**:会话结束 → AI 结构化摘要 +- **标签建议**:对话内容 → AI 建议分类标签 +- **知识库优化建议**:对话分析 → 知识库改进建议 + +**新增能力(设计完成,见 §15.8)**: +- **实时自动补齐**:输入停顿 > 0.8s → 幽灵文字 → Tab 接受 +- **语气调整**:选中文字 → 专业/友好/简洁 → 一键改写 +- **文字润色**:扩写/压缩/纠错 → 精修面板 → 确认替换 +- **智能改写**:对话上下文 + 知识库 → 3 个备选版本 + +**布局重构(设计完成,见 §15.8)**: +- 回复前功能(草稿/知识/推荐/快回)统一到中栏回复建议区 +- 回复后功能(标注/反馈/贡献/统计)归右栏 AI 训练区 +- 右栏支持放大/缩小模式切换(260px ↔ 560px) --- @@ -850,6 +878,142 @@ class SecurityStatus(BaseModel): --- +### 15.8 坐席端AI辅助消息框与布局优化 + +> **新增日期**: 2026-07-11 (v2.1) | **架构师**: 宋献 (Simon) +> **关联文档**: `docs/03-技术架构/坐席端AI辅助消息框与布局优化-架构设计.md` +> **关联PRD**: `docs/02-产品需求/坐席端AI辅助消息框-PRD.md` + `docs/02-产品需求/坐席端布局优化建议.md` + +#### 15.8.1 功能概述 + +在现有 Wingman 基础上新增 4 项 AI 辅助功能 + 坐席端布局全面重构: + +**AI 辅助消息框(4 项新增功能)**: + +| 功能 | 交互方式 | 后端方法 | temperature | +|------|---------|---------|-------------| +| 实时自动补齐 | 内联幽灵文字,Tab 接受,debounce 800ms | `generate_completion()` | 0.2 | +| 语气调整 | 选中文字 → 专业/友好/简洁 → 原文/改写对比 | `adjust_tone()` | 0.3 | +| 文字润色 | 点润色按钮 → 扩写/压缩/纠错 → 精修面板 | `polish_text()` | 0.3 | +| 智能改写 | 点改写按钮 → 3 个备选版本 → 替换/追加 | `rewrite_versions()` | 0.6 | + +**布局重构**: + +| 改动区域 | 变化 | +|----------|------| +| 左栏 | 280px → 260px(会话用户列表) | +| 中栏 | +80px 宽度;新增回复建议区;UserInfoBar/TroubleshootBar 默认折叠 | +| 右栏 | 320px → 260px(正常)/560px(放大);改为 AI 训练区;支持模式切换 | +| 工具栏 | 单行左右分区:常规工具(左) + AI工具(右) | + +#### 15.8.2 后端架构 + +**WingmanService 扩展**(`backend/app/services/wingman_service.py`): + +``` +WingmanService + ├── 现有方法(保持不变) + │ ├── generate_draft() temp=0.3 + │ ├── generate_summary() temp=0.3 + │ ├── suggest_tags() temp=0.3 + │ └── generate_knowledge_suggestion() + │ + ├── 新增方法 + │ ├── generate_completion() temp=0.2 ← 自动补齐 + │ ├── adjust_tone() temp=0.3 ← 语气调整 + │ ├── polish_text() temp=0.3 ← 文字润色 + │ └── rewrite_versions() temp=0.6 ← 智能改写 + │ + └── 改造方法 + └── _call_wingman_api(context, temperature=0.3) ← 新增可选参数 +``` + +**关键改造**:`_call_wingman_api()` 新增 `temperature` 参数(默认 0.3 保持兼容),各方法按需传递不同值。 + +**API 端点**(`backend/app/api/wingman.py`): + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/conversations/{id}/wingman/autocomplete` | POST | 自动补齐 | +| `/api/conversations/{id}/wingman/tone-adjust` | POST | 语气调整 | +| `/api/conversations/{id}/wingman/polish` | POST | 文字润色 | +| `/api/conversations/{id}/wingman/rewrite` | POST | 智能改写 | + +新增 Pydantic 请求模型(`backend/app/schemas/wingman_assist.py`):`AutocompleteRequest`、`ToneAdjustRequest`、`PolishRequest`、`RewriteRequest`,含字段验证和枚举类型。 + +#### 15.8.3 前端架构 + +**新增组件树**: + +``` +Workspace.vue + ├── ConversationList.vue (左栏 - 会话用户列表) + ├── ChatArea.vue (中栏) + │ ├── UserInfoBar.vue (详情默认折叠) + │ ├── TroubleshootBar.vue (默认折叠为图标条) + │ ├── MessageList.vue + │ ├── ReplySuggestArea.vue (新增 - 回复建议区) + │ │ ├── AiRecommendBar.vue (合并自 AiRecommendInline + AiSuggestReply) + │ │ └── QuickReplyBar.vue (改造自 QuickReplyPanel) + │ └── ReplyBox.vue (改造) + │ ├── GhostTextOverlay.vue (新增 - 幽灵文字) + │ ├── ToneAdjustPopover.vue (新增 - 语气浮层) + │ ├── PolishPanel.vue (新增 - 润色面板) + │ └── RewritePanel.vue (新增 - 改写面板) + └── AiAssistantPanel.vue (全面重构) + ├── PanelModeToggle.vue (新增 - 正常/放大切换) + └── AiTrainingPanel.vue (新增 - 训练区) + ├── SmartTagEditor.vue (智能标注) + ├── QualityFeedback.vue (质量反馈) + ├── KnowledgeContribute.vue (知识贡献) + └── UsageStats.vue (使用统计) +``` + +**新增 Composable**: + +| Composable | 职责 | +|------------|------| +| `useAutoComplete.ts` | debounce 800ms + AbortController + ghost text 管理 | +| `useAiTextTools.ts` | 语气/润色/改写统一调用和结果管理 | +| `usePanelMode.ts` | 右栏 260px ↔ 560px 模式切换 | + +**功能重复清理**(5 处): + +| 编号 | 重复类型 | 清理方案 | +|------|---------|---------| +| R1 | AI草稿双展示 | 统一到中栏回复建议区 | +| R2 | AI推荐回复三处展示 | 合并为 AiRecommendBar.vue | +| R3 | 排查流程命名混淆 | 右栏按钮重命名为"智能标注" | +| R4 | 标签建议双入口 | 合并为单一"智能标注"功能 | +| R5 | 孤儿组件 | 删除 AiRecommendInline.vue | + +#### 15.8.4 关键设计决策 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 补齐交互 | textarea + mirror div + ghost overlay | 保持现有快捷键和粘贴功能不变 | +| 补齐 API | HTTP + AbortController | 轻量请求,与现有 Wingman 调用一致 | +| 语气/润色浮层 | el-popover / el-drawer | 不遮挡消息列表,不打断工作流 | +| 右栏模式切换 | CSS 变量 + class 切换 | 组件不销毁,状态不丢失 | +| 回复建议区动画 | max-height transition | 平滑过渡,组件实例保持存活 | +| temperature 改造 | 可选参数默认 0.3 | 现有方法无需修改,向后兼容 | + +#### 15.8.5 开发计划 + +| 阶段 | 内容 | 预估 | +|------|------|------| +| Phase 1 | 后端 WingmanService 扩展 + Pydantic 模型 | 1.5 天 | +| Phase 2 | 前端 API 层 + Composable | 1 天 | +| Phase 3 | ReplyBox 工具栏 + AI 辅助组件 | 2 天 | +| Phase 4 | 布局重构 + 回复建议区 | 2 天 | +| Phase 5 | 右栏训练区 + 模式切换 | 1.5 天 | +| Phase 6 | 功能清理 + 联调测试 | 1 天 | +| **合计** | | **9 天** | + +> 完整架构设计(类图、时序图、API 规范、Dify prompt 模板、TypeScript 类型定义)详见独立文档:`docs/03-技术架构/坐席端AI辅助消息框与布局优化-架构设计.md` + +--- + ## 16. 技术分析报告 ### 16.1 JP-webcli自动化部署能力分析 diff --git a/docs/03-技术架构/class-exclusion-engine.mermaid b/docs/03-技术架构/class-exclusion-engine.mermaid new file mode 100644 index 0000000..7a499fe --- /dev/null +++ b/docs/03-技术架构/class-exclusion-engine.mermaid @@ -0,0 +1,38 @@ +%% 代答排除匹配引擎架构图 +%% 来源:增量设计-知识库迭代-开发任务分解-20260712.md §4.1 + +graph TB + subgraph "代答排除匹配引擎" + Engine[ExclusionService
匹配引擎入口] + Chain[ExclusionChain
责任链调度] + + subgraph "策略模式 - 4种匹配器" + M1[KeywordMatcher
关键词匹配] + M2[RegexMatcher
正则匹配] + M3[IntentMatcher
意图匹配] + M4[CategoryMatcher
分类排除] + end + + Engine --> Chain + Chain --> M1 + Chain --> M2 + Chain --> M3 + Chain --> M4 + end + + subgraph "外部依赖" + Dify[Dify 意图识别
复用审批意图链路] + TriageDB[triage_sessions
分诊结果] + RuleDB[(exclusion_rules)] + LogDB[(exclusion_logs)] + end + + M3 -->|调用| Dify + M4 -->|查询| TriageDB + Engine -->|读取规则| RuleDB + Engine -->|记录命中| LogDB + + style M1 fill:#dbeafe,stroke:#3b82f6 + style M2 fill:#fce7f3,stroke:#ec4899 + style M3 fill:#ede9fe,stroke:#8b5cf6 + style M4 fill:#dcfce7,stroke:#07C160 diff --git a/docs/03-技术架构/dependency-graph.mermaid b/docs/03-技术架构/dependency-graph.mermaid new file mode 100644 index 0000000..1820224 --- /dev/null +++ b/docs/03-技术架构/dependency-graph.mermaid @@ -0,0 +1,67 @@ +%% 模块依赖关系图 +%% 来源:增量设计-知识库迭代-开发任务分解-20260712.md §9 + +graph TB + subgraph "T01: 项目基础设施" + DB[数据库迁移
3张新表] + CFG[config.py
新增配置项] + MODEL[ORM 模型
3个] + SCHEMA[Pydantic Schema
2个文件] + ROUTER[router.py
路由注册] + end + + subgraph "T02: 分诊交互模块" + TRI_API[分诊 API
12个端点] + TRI_SVC[TriageService
+ DifyTriageService] + H5_FE[H5 前端
TriageCard + useTriage] + AG_FE[坐席端前端
TriageDashboard + 3组件] + DIFY_APP[Dify 分诊应用
Prompt + 配置] + end + + subgraph "T03: 拓扑预览模块" + TOPO_FE[管理后台前端
TopologyPreview + 3组件] + TOPO_API[复用已有
GET /graph] + end + + subgraph "T04: 代答排除模块" + EXC_API[排除管理 API
8个端点] + EXC_SVC[ExclusionService
+ 4个Matcher] + EXC_FE[管理后台前端
ExclusionRules + 2组件] + MSG_INT[消息流集成
ai_handler.py] + end + + %% 依赖关系 + DB --> TRI_API + DB --> EXC_API + CFG --> TRI_SVC + MODEL --> TRI_API + MODEL --> EXC_API + SCHEMA --> TRI_API + SCHEMA --> EXC_API + ROUTER --> TRI_API + ROUTER --> EXC_API + + TRI_API --> TRI_SVC + TRI_SVC --> H5_FE + TRI_SVC --> AG_FE + TRI_SVC --> DIFY_APP + + EXC_API --> EXC_SVC + EXC_SVC --> EXC_FE + EXC_SVC --> MSG_INT + + %% 软依赖 + TRI_SVC -.->|"CategoryMatcher
软依赖分诊结果"| EXC_SVC + + %% 拓扑预览独立 + TOPO_FE --> TOPO_API + + %% 并行标注 + style TOPO_FE fill:#e8f5e9,stroke:#4caf50,stroke-dasharray: 5 5 + style TOPO_API fill:#e8f5e9,stroke:#4caf50,stroke-dasharray: 5 5 + + style DB fill:#e3f2fd,stroke:#2196f3 + style CFG fill:#e3f2fd,stroke:#2196f3 + style MODEL fill:#e3f2fd,stroke:#2196f3 + style SCHEMA fill:#e3f2fd,stroke:#2196f3 + style ROUTER fill:#e3f2fd,stroke:#2196f3 diff --git a/docs/03-技术架构/sequence-triage-flow.mermaid b/docs/03-技术架构/sequence-triage-flow.mermaid new file mode 100644 index 0000000..965e516 --- /dev/null +++ b/docs/03-技术架构/sequence-triage-flow.mermaid @@ -0,0 +1,74 @@ +%% 分诊主流程时序图 +%% 来源:增量设计-知识库迭代-开发任务分解-20260712.md §2.5 + +sequenceDiagram + participant H5 as H5 员工端 + participant API as FastAPI 后端 + participant TriSvc as TriageService + participant DifyTri as Dify 分诊应用 + participant DB as PostgreSQL + participant AG as 坐席端看板 + participant WS as WebSocket + + rect rgb(255, 243, 224) + Note over H5,DB: 阶段1:发起分诊 + H5->>API: POST /api/h5/triage/start {conversation_id, question} + API->>TriSvc: start_triage(conversation_id, question) + TriSvc->>DB: INSERT triage_sessions (status=triaging) + TriSvc->>DifyTri: 调用分诊 prompt(拆分问题为分步选择题) + alt Dify 5秒内响应 + DifyTri-->>TriSvc: {steps[], confidence, urgency, suggested_route, problem_type} + TriSvc->>DB: UPDATE triage_sessions SET triage_steps, confidence, urgency, suggested_route + TriSvc-->>API: {triage_id, steps, total, confidence, urgency, suggested_route} + API-->>H5: {triage_id, steps, total, ...} + else Dify 超时(>5s) + TriSvc->>DB: UPDATE triage_sessions SET status=timeout + TriSvc-->>API: 超时,自动转人工 + API-->>H5: 分诊超时,已转人工 + end + end + + rect rgb(227, 242, 253) + Note over H5,DB: 阶段2:分步选择 + 坐席协同 + loop 每一步 + H5->>API: POST /api/h5/triage/step {triage_id, step_index, selected_label} + API->>TriSvc: submit_step(triage_id, step_index, selected_label) + TriSvc->>DB: 记录 collected_context + TriSvc->>DifyTri: 根据选择动态调整后续步骤 + DifyTri-->>TriSvc: next_step + TriSvc-->>API: {next_step, collected_context} + API-->>H5: {next_step, collected_context} + end + + Note over AG: 坐席看板实时查看分诊进度 + AG->>API: GET /api/agent/triage/pending + API-->>AG: 待分诊列表 + AG->>API: GET /api/agent/triage/{triage_id} + API-->>AG: 分诊详情 + + opt 坐席排除选项 + AG->>API: POST /api/agent/triage/{triage_id}/exclude-options {excluded_labels} + API->>WS: WS 推送 excluded_labels 到 H5 + WS-->>H5: {type: "triage_exclude", excluded_labels} + H5->>H5: TriageCard.setExcludedOptions(labels) + end + end + + rect rgb(232, 245, 233) + Note over H5,DB: 阶段3:分诊完成 / 转人工 + alt 所有步骤完成 + H5->>API: POST /api/h5/triage/complete {triage_id, context} + API->>TriSvc: complete_triage(triage_id, context) + TriSvc->>DifyTri: 根据收集的上下文生成最终回复 + DifyTri-->>TriSvc: {reply, confidence} + TriSvc->>DB: UPDATE triage_sessions SET status=routed, route_action=ai_self + TriSvc-->>API: {reply, confidence} + API-->>H5: AI 回复 + else 转人工 + H5->>API: POST /api/h5/triage/transfer {triage_id, context} + API->>TriSvc: transfer_to_human(triage_id, context) + TriSvc->>DB: UPDATE triage_sessions SET status=routed, route_action=human + TriSvc-->>API: 转人工成功 + API-->>H5: 已转人工 + end + end diff --git a/docs/03-技术架构/坐席端AI辅助消息框与布局优化-架构设计.md b/docs/03-技术架构/坐席端AI辅助消息框与布局优化-架构设计.md new file mode 100644 index 0000000..ebef0c1 --- /dev/null +++ b/docs/03-技术架构/坐席端AI辅助消息框与布局优化-架构设计.md @@ -0,0 +1,1502 @@ +# 坐席端 AI 辅助消息框与布局优化 — 系统架构设计 + +> **文档版本**: v1.0 +> **日期**: 2026-07-11 +> **架构师**: 宋献 (Simon) +> **关联 PRD**: `docs/02-产品需求/坐席端AI辅助消息框-PRD.md` +> **关联布局方案**: `docs/02-产品需求/坐席端布局优化建议.md` +> **技术栈**: Vue 3 + TypeScript + Element Plus + FastAPI + Dify AI + +--- + +## 目录 + +- [Part A: 系统设计](#part-a-系统设计) + - [1. 架构概述](#1-架构概述) + - [2. 后端架构设计](#2-后端架构设计) + - [3. 前端架构设计](#3-前端架构设计) + - [4. 布局重构架构设计](#4-布局重构架构设计) + - [5. 数据流设计](#5-数据流设计) + - [6. 关键设计决策](#6-关键设计决策) +- [Part B: 接口与数据结构](#part-b-接口与数据结构) + - [7. API 规范](#7-api-规范) + - [8. Pydantic 模型](#8-pydantic-模型) + - [9. Dify Prompt 模板](#9-dify-prompt-模板) + - [10. TypeScript 类型定义](#10-typescript-类型定义) +- [Part C: 架构图](#part-c-架构图) + - [11. 类图](#11-类图) + - [12. 时序图](#12-时序图) +- [Part D: 任务分解](#part-d-任务分解) + - [13. 文件清单](#13-文件清单) + - [14. 开发计划](#14-开发计划) + - [15. 风险与缓解](#15-风险与缓解) + +--- + +## Part A: 系统设计 + +### 1. 架构概述 + +#### 1.1 功能范围 + +本设计覆盖两大模块: + +| 模块 | 功能 | PRD 参考 | +|------|------|---------| +| AI 辅助消息框 | 实时自动补齐、语气调整、文字润色、智能改写 | `坐席端AI辅助消息框-PRD.md` §2 | +| 布局优化 | 回复建议区、工具栏重构、右栏训练区、放大/缩小开关 | `坐席端布局优化建议.md` §3 | + +#### 1.2 系统架构图 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 坐席工作台 (Vue3 + Element Plus) │ +│ │ +│ ┌──────────┐ ┌───────────────────┐ ┌───────────────────────┐ │ +│ │ 左栏 │ │ 中栏 │ │ 右栏 │ │ +│ │ 会话用户 │ │ ┌──────────────┐ │ │ ┌───────────────────┐ │ │ +│ │ 列表 │ │ │ UserInfoBar │ │ │ │ PanelModeToggle │ │ │ +│ │ +待办 │ │ ├──────────────┤ │ │ │ (正常/放大) │ │ │ +│ │ │ │ │ Troubleshoot │ │ │ ├───────────────────┤ │ │ +│ │ │ │ ├──────────────┤ │ │ │ AiTrainingPanel │ │ │ +│ │ │ │ │ 消息列表 │ │ │ │ ├ SmartTagEditor │ │ │ +│ │ │ │ ├──────────────┤ │ │ │ ├ QualityFeedback │ │ │ +│ │ │ │ │ReplySuggestArea│ │ │ │ ├ KnowledgeContrib│ │ │ +│ │ │ │ │ (AI推荐+快回) │ │ │ │ └ UsageStats │ │ │ +│ │ │ │ ├──────────────┤ │ │ └───────────────────┘ │ │ +│ │ │ │ │ ReplyBox │ │ │ │ │ +│ │ │ │ │ ┌──────────┐ │ │ │ │ │ +│ │ │ │ │ │工具栏 │ │ │ │ │ │ +│ │ │ │ │ │常规|AI │ │ │ │ │ │ +│ │ │ │ │ └──────────┘ │ │ │ │ │ +│ │ │ │ │ textarea │ │ │ │ │ +│ │ │ │ └──────────────┘ │ │ │ │ +│ └──────────┘ └───────────────────┘ └───────────────────────┘ │ +└──────────────────────────┬───────────────────────────────────────┘ + │ HTTP / WebSocket + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ FastAPI 后端 │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ wingman.py (路由层) │ │ +│ │ POST /wingman/autocomplete ← 自动补齐 │ │ +│ │ POST /wingman/tone-adjust ← 语气调整 │ │ +│ │ POST /wingman/polish ← 文字润色 │ │ +│ │ POST /wingman/rewrite ← 智能改写 │ │ +│ │ + 现有: draft / summary / tags │ │ +│ └────────────────────────┬────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────▼────────────────────────────────┐ │ +│ │ WingmanService (服务层) │ │ +│ │ generate_completion() ← 新增: 自动补齐 │ │ +│ │ adjust_tone() ← 新增: 语气调整 │ │ +│ │ polish_text() ← 新增: 文字润色 │ │ +│ │ rewrite_versions() ← 新增: 智能改写 │ │ +│ │ + 现有: generate_draft / generate_summary / suggest_tags│ │ +│ │ │ │ +│ │ _call_wingman_api() ← 改造: 支持 temperature 参数 │ │ +│ │ _build_context_messages() ← 复用: 角色映射 │ │ +│ └────────────────────────┬────────────────────────────────┘ │ +│ │ httpx (OpenAI 兼容格式) │ +└───────────────────────────┼──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Dify AI (外部服务) │ +│ Wingman Agent: /chat/completions (OpenAI 兼容) │ +│ RAGFlow: 知识库检索 (智能改写时调用) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +#### 1.3 与现有系统的关系 + +| 现有组件 | 关系 | 说明 | +|----------|------|------| +| `WingmanService` | 扩展 | 新增 4 个方法,改造 `_call_wingman_api()` 支持 temperature | +| `wingman.py` 路由 | 扩展 | 新增 4 个端点 + Pydantic 请求模型 | +| `wingman.ts` API 层 | 扩展 | 新增 4 个前端 API 调用函数 | +| `ReplyBox.vue` | 改造 | 工具栏重构 + AI 工具按钮 + 补齐逻辑 | +| `AiAssistantPanel.vue` | 全面重构 | 移除回复前功能,改为训练区 | +| `AiRecommendInline.vue` | 删除 | 功能被 `AiRecommendBar.vue` 替代 | +| `AiSuggestReply.vue` | 删除 | 功能合并到 `AiRecommendBar.vue` | +| `QuickReplyPanel.vue` | 改造 | 重命名为 `QuickReplyBar.vue`,横向布局 | + +--- + +### 2. 后端架构设计 + +#### 2.1 WingmanService 扩展设计 + +现有 `WingmanService` 包含 4 个方法(generate_draft / generate_summary / suggest_tags / generate_knowledge_suggestion),全部通过 `_call_wingman_api()` 调用 Dify。本设计新增 4 个方法并改造底层调用。 + +**新增方法签名**: + +```python +class WingmanService: + # === 现有方法(保持不变) === + # generate_draft() + # generate_summary() + # suggest_tags() + # generate_knowledge_suggestion() + + # === 新增方法 === + async def generate_completion( + self, + conversation_id: str, + current_text: str, + messages: List[Dict[str, Any]], + max_length: int = 80, + ) -> Dict[str, Any]: + """自动补齐:根据坐席当前输入内容补齐下一句""" + + async def adjust_tone( + self, + conversation_id: str, + selected_text: str, + full_text: str, + tone: str, # professional / friendly / concise + messages: List[Dict[str, Any]], + ) -> Dict[str, Any]: + """语气调整:将选中文字改写为指定风格""" + + async def polish_text( + self, + conversation_id: str, + text: str, + action: str, # expand / compress / correct + messages: List[Dict[str, Any]], + ) -> Dict[str, Any]: + """文字润色:对输入框全部内容进行扩写/压缩/纠错""" + + async def rewrite_versions( + self, + conversation_id: str, + current_text: str, + messages: List[Dict[str, Any]], + generate_count: int = 3, + include_knowledge: bool = True, + ) -> Dict[str, Any]: + """智能改写:基于对话上下文+知识库生成多个备选版本""" +``` + +#### 2.2 _call_wingman_api 改造 + +**当前问题**:`_call_wingman_api()` 的 temperature 硬编码为 0.3,所有方法共用。新功能需要不同 temperature: + +| 方法 | temperature | 理由 | +|------|-------------|------| +| generate_completion | 0.2 | 补齐需要高确定性,避免创造性发散 | +| adjust_tone | 0.3 | 改写需保持原意,适度创造性 | +| polish_text | 0.3 | 润色需保持原意,适度创造性 | +| rewrite_versions | 0.6 | 改写需要多样化输出,提高创造性 | +| generate_draft (现有) | 0.3 | 保持不变 | +| generate_summary (现有) | 0.3 | 保持不变 | + +**改造方案**: + +```python +# 改造前(当前代码) +async def _call_wingman_api( + self, context_messages: List[Dict[str, str]] +) -> Optional[str]: + payload = { + "model": "Chat", + "messages": context_messages, + "stream": False, + "temperature": 0.3, # ← 硬编码 + } + +# 改造后 +async def _call_wingman_api( + self, + context_messages: List[Dict[str, str]], + temperature: float = 0.3, # ← 新增参数,默认值保持兼容 +) -> Optional[str]: + payload = { + "model": "Chat", + "messages": context_messages, + "stream": False, + "temperature": temperature, + } +``` + +**兼容性**:现有 4 个方法调用时不传 temperature 参数,自动使用默认值 0.3,行为不变。 + +#### 2.3 上下文构建策略 + +现有 `_build_context_messages()` 将完整对话历史构建为 OpenAI Chat 格式。新功能需要差异化上下文: + +| 方法 | 上下文消息条数 | 附加内容 | 原因 | +|------|--------------|---------|------| +| generate_completion | 最近 5 条 | 当前输入文本拼入最后一条 user 消息 | 补齐需要最近上下文 + 当前输入 | +| adjust_tone | 最近 5 条 | 选中文字 + 完整输入内容作为指令 | 改写需要理解选中文字的上下文 | +| polish_text | 最近 5 条 | 全部输入内容作为指令 | 润色针对输入框全部内容 | +| rewrite_versions | 最近 10 条 | 知识库检索结果(可选) | 改写需要更完整上下文 + 知识库引用 | + +**实现方式**:复用 `_build_context_messages()` 构建基础对话上下文,然后在方法内部追加功能特定的指令消息: + +```python +async def generate_completion(self, ...): + # 1. 构建基础上下文(最近5条对话 → system prompt + 历史消息) + context = self._build_context_messages(messages, self._COMPLETION_SYSTEM_PROMPT) + + # 2. 追加当前输入作为最后一条 user 消息 + context.append({ + "role": "user", + "content": f"坐席正在输入的内容:{current_text}\n请补齐下一句话。" + }) + + # 3. 调用 Dify(temperature=0.2) + result = await self._call_wingman_api(context, temperature=0.2) +``` + +#### 2.4 智能改写的知识库集成 + +`rewrite_versions()` 在生成"带知识库引用"版本时需要调用 RAGFlow 检索: + +``` +坐席点击"改写" + → 后端从对话消息提取关键词 + → 调用 RAGFlow API 检索相关知识 + → 将检索结果注入 Dify prompt + → Dify 生成 3 个版本(含知识库引用版本) +``` + +**RAGFlow 调用**:复用现有 `RAGFlowClient`(位于 `app/integrations/`),无需新建。若 RAGFlow 不可用,降级为仅基于对话上下文生成 2 个版本。 + +#### 2.5 降级策略 + +与现有方法保持一致:Dify 不可用时返回默认值而非抛异常。 + +```python +# 降级返回示例(generate_completion) +{ + "completion": "", + "confidence": 0.0, + "error": "Wingman 服务暂不可用" +} + +# 降级返回示例(rewrite_versions) +{ + "versions": [], + "error": "AI 服务暂不可用,请稍后重试" +} +``` + +--- + +### 3. 前端架构设计 + +#### 3.1 组件架构图 + +``` +Workspace.vue (主布局) + ├── ConversationList.vue (左栏 - 会话用户列表) + │ └── TodoPanel.vue (待办面板) + │ + ├── ChatArea.vue (中栏) + │ ├── UserInfoBar.vue (用户信息栏 - 详情默认折叠) + │ ├── TroubleshootBar.vue (排查步骤栏 - 默认折叠为图标条) + │ ├── MessageList.vue (消息列表) + │ ├── ReplySuggestArea.vue (回复建议区 - 新增) + │ │ ├── AiRecommendBar.vue (AI推荐条 - 合并自 AiRecommendInline + AiSuggestReply) + │ │ └── QuickReplyBar.vue (快速回复条 - 改造自 QuickReplyPanel) + │ └── ReplyBox.vue (输入框 - 改造) + │ ├── 工具栏 (单行左右分区) + │ │ ├── 常规工具组: [截图] [拍照] [表情] [文件] [邀请] + │ │ └── AI工具组: [补齐toggle] [语气] [润色] [改写] + │ ├── GhostTextOverlay.vue (幽灵文字覆盖层 - 新增) + │ ├── ToneAdjustPopover.vue (语气调整浮层 - 新增) + │ ├── PolishPanel.vue (润色精修面板 - 新增) + │ └── RewritePanel.vue (改写选择面板 - 新增) + │ + └── AiAssistantPanel.vue (右栏 - 全面重构) + ├── PanelModeToggle.vue (模式切换: 正常/放大 - 新增) + └── AiTrainingPanel.vue (训练区主体 - 新增) + ├── SmartTagEditor.vue (智能标注) + ├── QualityFeedback.vue (质量反馈) + ├── KnowledgeContribute.vue (知识贡献) + └── UsageStats.vue (使用统计) +``` + +#### 3.2 Composable 设计 + +新增 3 个 composable 封装 AI 辅助逻辑: + +| Composable | 职责 | 状态范围 | +|------------|------|---------| +| `useAutoComplete.ts` | 自动补齐的 debounce、请求、幽灵文字管理 | ReplyBox 局部 | +| `useAiTextTools.ts` | 语气/润色/改写的统一调用和结果管理 | ReplyBox 局部 | +| `usePanelMode.ts` | 右栏放大/缩小模式切换 | Workspace 全局 | + +**useAutoComplete.ts 核心逻辑**: + +```typescript +export function useAutoComplete( + textareaRef: Ref, + conversationId: Ref, + enabled: Ref, +) { + const ghostText = ref('') // 幽灵文字内容 + const isLoading = ref(false) // 加载状态 + const abortController = ref(null) + + // debounce 800ms,输入停顿后触发 + const debouncedFetch = useDebounceFn(async (text: string) => { + if (!enabled.value || text.length < 5) { + ghostText.value = '' + return + } + // 取消上一个请求 + abortController.value?.abort() + abortController.value = new AbortController() + + isLoading.value = true + try { + const result = await wingmanApi.autocomplete( + conversationId.value, + { current_text: text, cursor_position: text.length }, + { signal: abortController.value.signal }, + ) + ghostText.value = result.completion || '' + } catch (e) { + if (!isAbortError(e)) ghostText.value = '' + } finally { + isLoading.value = false + } + }, 800) + + // Tab 接受补齐 + function acceptCompletion() { + // 将 ghostText 追加到输入框 + // 清空 ghostText + } + + // Esc 清除补齐 + function dismissCompletion() { + ghostText.value = '' + } + + return { ghostText, isLoading, debouncedFetch, acceptCompletion, dismissCompletion } +} +``` + +**usePanelMode.ts 核心逻辑**: + +```typescript +export function usePanelMode() { + const mode = ref<'normal' | 'expanded'>('normal') + + const panelWidth = computed(() => + mode.value === 'normal' ? '260px' : '560px' + ) + + const centerColVisible = computed(() => + mode.value === 'normal' + ) + + function toggleMode() { + mode.value = mode.value === 'normal' ? 'expanded' : 'normal' + } + + return { mode, panelWidth, centerColVisible, toggleMode } +} +``` + +#### 3.3 幽灵文字实现方案 + +PRD 确认为内联幽灵文字。实现方式为 textarea 上方覆盖透明文字层: + +``` +┌──────────────────────────────┐ +│ textarea (z-index: 1) │ ← 文字颜色: #303133 (正常) +│ 透明背景,文字不可见的部分 │ 选中文字后的补齐文字不可见 +├──────────────────────────────┤ +│ ghost overlay (z-index: 2) │ ← 文字颜色: #C0C4CC (灰色) +│ pointer-events: none │ 仅在 textarea 文字末尾之后显示 +│ 位置与 textarea 文字完全对齐 │ 通过 mirror div 技术计算光标坐标 +└──────────────────────────────┘ +``` + +**技术要点**: + +1. 创建一个与 textarea 样式完全一致的 `
`(mirror div),用于计算光标坐标 +2. ghost overlay 定位在光标坐标处,显示灰色补齐文字 +3. `pointer-events: none` 确保不拦截鼠标事件 +4. Tab 键监听在 textarea 的 `keydown` 事件中,`e.key === 'Tab'` 时阻止默认行为并接受补齐 +5. 继续输入时自动清除 ghost text(`input` 事件触发 debounce 重新请求) + +**Mirror div 技术**(用于精确计算光标位置): + +```typescript +function getCursorCoordinates(textarea: HTMLTextAreaElement, position: number) { + const div = document.createElement('div') + // 复制 textarea 的所有影响文字布局的样式 + const styles = window.getComputedStyle(textarea) + Object.assign(div.style, { + position: 'absolute', + visibility: 'hidden', + whiteSpace: 'pre-wrap', + wordWrap: 'break-word', + // ... 复制 font-family, font-size, padding, border, width 等 + }) + // 截取光标位置之前的文字 + div.textContent = textarea.value.substring(0, position) + document.body.appendChild(div) + // 创建一个 span 标记光标位置 + const span = document.createElement('span') + span.textContent = '|' + div.appendChild(span) + // 获取 span 的坐标(相对于 textarea) + const coords = { + x: span.offsetLeft - textarea.scrollLeft, + y: span.offsetTop - textarea.scrollTop, + } + document.body.removeChild(div) + return coords +} +``` + +#### 3.4 前端 API 层扩展 + +在 `frontend-agent/src/api/wingman.ts` 中新增 4 个函数: + +```typescript +// 自动补齐 +export async function autocomplete( + conversationId: string, + data: { current_text: string; cursor_position: number; max_length?: number }, + config?: { signal?: AbortSignal }, +): Promise<{ completion: string; confidence: number }> + +// 语气调整 +export async function toneAdjust( + conversationId: string, + data: { selected_text: string; full_text: string; tone: string }, +): Promise<{ rewritten_text: string; tone: string; changes_summary: string }> + +// 文字润色 +export async function polish( + conversationId: string, + data: { text: string; action: string }, +): Promise<{ polished_text: string; action: string; changes_summary: string }> + +// 智能改写 +export async function rewrite( + conversationId: string, + data: { current_text: string; generate_count?: number; include_knowledge?: boolean }, +): Promise<{ versions: Array<{ text: string; style: string; source: string }> }> +``` + +--- + +### 4. 布局重构架构设计 + +#### 4.1 CSS 变量体系 + +```css +:root { + /* === 布局尺寸 === */ + --sidebar-width: 260px; /* 左栏: 280px → 260px */ + --assistant-panel-width: 260px; /* 右栏正常: 320px → 260px */ + --assistant-panel-expanded: 560px; /* 右栏放大: 新增 */ + + /* === 右栏模式控制 === */ + --center-col-flex: 1; /* 正常: 中栏弹性 */ + --center-col-width: auto; /* 正常: 中栏自动 */ + --center-col-overflow: visible; /* 正常: 可见 */ + + /* === 回复建议区颜色 === */ + --suggest-ai-bg: #FAECE7; /* AI推荐背景 - 珊瑚色 */ + --suggest-ai-border: #F0997B; + --suggest-ai-text: #993C1D; + --suggest-quick-bg: #EAF3DE; /* 快速回复背景 - 绿色 */ + --suggest-quick-border: #97C459; + --suggest-quick-text: #27500A; + + /* === 工具栏分组颜色 === */ + --toolbar-conv-bg: #E1F5EE; /* 常规工具 - 绿色系 */ + --toolbar-ai-bg: #FAEEDA; /* AI工具 - 琥珀色系 */ + + /* === 回复建议区高度 === */ + --suggest-area-height: auto; /* 动态: 选中后 0 */ + --suggest-ai-height: 44px; /* AI推荐区固定高度 */ + --suggest-quick-height: 56px; /* 快速回复区固定2层 */ +} +``` + +#### 4.2 右栏放大/缩小模式实现 + +```vue + + + + +``` + +#### 4.3 回复建议区动态高度 + +```vue + + + + + + +``` + +#### 4.4 工具栏单行左右分区 + +```vue + + +``` + +--- + +### 5. 数据流设计 + +#### 5.1 自动补齐数据流 + +``` +坐席输入文字 + → textarea input 事件 + → useAutoComplete.debouncedFetch(text) [debounce 800ms] + → 取消上一个 AbortController + → 调用 wingmanApi.autocomplete(conversationId, { current_text }) + → POST /api/conversations/{id}/wingman/autocomplete + → FastAPI 路由层 + → _get_recent_messages(conversationId, db, limit=5) + → WingmanService.generate_completion(conversationId, text, messages) + → _build_context_messages(messages, _COMPLETION_SYSTEM_PROMPT) + → 追加 current_text 到上下文末尾 + → _call_wingman_api(context, temperature=0.2) + → Dify API POST /chat/completions + → 解析 choices[0].message.content + → 返回 { completion, confidence } + → 前端设置 ghostText.value = result.completion + → ghost overlay 渲染灰色文字 + +坐席按 Tab + → acceptCompletion() + → inputText.value += ghostText.value + → ghostText.value = '' +``` + +#### 5.2 语气调整数据流 + +``` +坐席选中文字 → 点击"语气"按钮 → 弹出 ToneAdjustPopover + → 选择"专业/友好/简洁" + → 调用 wingmanApi.toneAdjust(conversationId, { selected_text, full_text, tone }) + → POST /api/conversations/{id}/wingman/tone-adjust + → WingmanService.adjust_tone(...) + → _build_context_messages(messages, _TONE_SYSTEM_PROMPT) + → 注入 selected_text + full_text + tone 指令 + → _call_wingman_api(context, temperature=0.3) + → 返回 { rewritten_text, tone, changes_summary } + → 浮层显示"原文 → 改写文"对比 + → 坐席点击"替换" → 用 rewritten_text 替换选中区域 + → 坐席点击"取消" → 关闭浮层,不修改 +``` + +#### 5.3 右栏模式切换数据流 + +``` +坐席点击 PanelModeToggle "放大"按钮 + → usePanelMode.toggleMode() + → mode.value = 'expanded' + → CSS 变量更新: + --assistant-panel-width → 560px + --center-col-flex → 0 + --center-col-width → 0 + --center-col-overflow → hidden + → CSS transition 0.3s 平滑动画 + → 中栏压缩隐藏,右栏展开为 560px + +坐席点击"正常"按钮 + → toggleMode() → mode.value = 'normal' + → CSS 变量恢复 + → 中栏恢复显示,右栏缩回 260px +``` + +--- + +### 6. 关键设计决策 + +#### 决策 1:自动补齐用 Mirror Div 而非 contenteditable + +**选择**:保持 `