feat: 2026-07-12~13 全量更新 - AI对话链路改造+H5 v4/v5+坐席端v5+上下文感知诊断+知识库迭代3

## 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分块上传
This commit is contained in:
Simon
2026-07-13 02:17:03 +08:00
parent bea288e414
commit 449c6d4875
176 changed files with 46637 additions and 4805 deletions
+31 -36
View File
@@ -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]]:
+28 -64
View File
@@ -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)
+381
View File
@@ -0,0 +1,381 @@
# =============================================================================
# 企微IT智能服务台 — 代答排除管理 API
# =============================================================================
# 说明:代答排除规则的管理后台接口,共 8 个端点。
# 路由前缀:/admin/exclusion-rulesrouter.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。
+288 -21
View File
@@ -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.1Phase 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)
+207
View File
@@ -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)}")
+108
View File
@@ -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)
+141
View File
@@ -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)
+261
View File
@@ -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,
}
+36
View File
@@ -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=["测验题目管理"])
+421
View File
@@ -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,
}
+40 -21
View File
@@ -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)