Files
wecom_it_smart_desk/backend/app/api/automation.py
T

449 lines
16 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — 阶段5 自动化闭环 API
# =============================================================================
# 说明:提供自动化会话的 REST 接口与专用 WebSocket 通道。
# 前缀(经 Vite/ nginx 剥离 /api 后):/itportal/automation
#
# 坐席端(agent):
# POST /itportal/automation/sessions — 创建并启动会话
# GET /itportal/automation/sessions — 会话列表
# GET /itportal/automation/sessions/{id} — 会话详情
# POST /itportal/automation/sessions/{id}/approve — 坐席审批/驳回
# POST /itportal/automation/sessions/{id}/takeover — 转人工接管
#
# 员工端(H5):
# POST /itportal/automation/sessions/by-employee — 员工创建会话
# GET /itportal/automation/sessions/{id}/employee — 员工查看详情
# POST /itportal/automation/sessions/{id}/confirm — 员工 H5 二次确认
# POST /itportal/automation/sessions/{id}/feedback — 员工结果反馈
#
# 管理端(admin,配置写需 OTP):
# GET /itportal/automation/admin/scenarios — 场景配置列表
# PUT /itportal/automation/admin/scenarios/{key} — 更新场景配置(OTP)
# GET /itportal/automation/admin/rule-versions — 规则版本列表
# GET /itportal/automation/admin/metrics — 看板指标
#
# WebSocket
# /ws/automation/{session_id} — 自动化进度/审批/确认实时推送
# =============================================================================
from __future__ import annotations
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import require_high_risk_otp
from app.dependencies.automation import get_current_employee_id
from app.api.agents import get_current_agent
from app.models.agent import Agent
from app.schemas.automation import (
CreateSessionRequest,
ResolutionFeedbackRequest,
ScenarioConfigResponse,
ScenarioConfigUpdate,
SessionResponse,
ApprovalDecisionRequest,
ConfirmRequest,
RuleVersionResponse,
ResolveFeedbackRequest,
TakeoverRequest,
AutoMetricsResponse,
serialize_action,
serialize_approval,
serialize_session,
)
from app.services.automation import (
ActionExecutor,
AutoSessionService,
AutomationException,
to_app_exception,
)
from app.services.automation.progress_publisher import (
register_ws,
set_parties,
unregister_ws,
)
from app.services.cache_service import cache_service
from app.utils.response import AppException, success_response
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------
# REST 路由器(前缀 /itportal/automation,经 /api 代理剥离)
# --------------------------------------------------------------------------
router = APIRouter(prefix="/itportal/automation")
# --------------------------------------------------------------------------
# WebSocket 路由器(根路径 /ws/automation/{session_id}
# --------------------------------------------------------------------------
ws_router = APIRouter()
# WS 认证失败关闭码(与 ws.py 保持一致)
WS_CLOSE_UNAUTHORIZED = 4001
# ==========================================================================
# 坐席端接口
# ==========================================================================
@router.post("/sessions", tags=["自动化闭环"])
async def create_session(
req: CreateSessionRequest,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席创建自动化会话并启动后台编排。"""
svc = AutoSessionService(db)
session = await svc.create_session(
conversation_id=req.conversation_id,
employee_id=req.employee_id,
description=req.description,
mode=req.mode,
)
await db.flush()
# 记录参与方,供进度兜底推送
set_parties(session.id, employee_id=req.employee_id)
await db.commit()
# 后台运行编排(不阻塞响应)
asyncio.create_task(_run_background(session.id))
data = serialize_session(session)
return success_response(data.model_dump() if hasattr(data, "model_dump") else data.__dict__)
@router.get("/sessions", tags=["自动化闭环"])
async def list_sessions(
employee_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
_agent: Agent = Depends(get_current_agent),
):
"""坐席查看自动化会话列表。"""
svc = AutoSessionService(db)
sessions = await svc.list_sessions(
employee_id=employee_id, status=status, page=page, page_size=page_size
)
return success_response([_session_min(s) for s in sessions])
@router.get("/sessions/{session_id}", tags=["自动化闭环"])
async def get_session(
session_id: str,
db: AsyncSession = Depends(get_db),
_agent: Agent = Depends(get_current_agent),
):
"""坐席查看会话详情。"""
svc = AutoSessionService(db)
detail = await svc.get_session_detail(session_id)
if detail is None:
raise AppException(4005, "自动化会话不存在")
return success_response(_detail_payload(detail))
@router.post("/sessions/{session_id}/approve", tags=["自动化闭环"])
async def approve_session(
session_id: str,
req: ApprovalDecisionRequest,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席审批/驳回当前待决高危动作。"""
svc = AutoSessionService(db)
detail = await svc.get_session_detail(session_id)
if detail is None:
raise AppException(4005, "自动化会话不存在")
action_id = detail["session"].current_action_id
if not action_id or detail["ticket"] is None:
raise AppException(4004, "当前没有待审批的动作")
executor = ActionExecutor(db)
try:
await executor.resume(
session_id,
action_id,
decision=req.decision,
note=req.note,
approver_id=current_agent.user_id,
)
except AutomationException as e:
raise to_app_exception(e)
await db.commit()
return success_response(_detail_payload(await svc.get_session_detail(session_id)))
@router.post("/sessions/{session_id}/takeover", tags=["自动化闭环"])
async def takeover_session(
session_id: str,
req: TakeoverRequest,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席转人工接管会话。"""
svc = AutoSessionService(db)
try:
session = await svc.takeover(
session_id, agent_id=current_agent.user_id, note=req.note
)
except AutomationException as e:
raise to_app_exception(e)
await db.commit()
return success_response(serialize_session(session).__dict__)
# ==========================================================================
# 员工端(H5)接口
# ==========================================================================
@router.post("/sessions/by-employee", tags=["自动化闭环"])
async def create_session_by_employee(
req: CreateSessionRequest,
db: AsyncSession = Depends(get_db),
employee_id: str = Depends(get_current_employee_id),
):
"""员工(H5)创建自动化会话。"""
svc = AutoSessionService(db)
session = await svc.create_session(
conversation_id=req.conversation_id,
employee_id=employee_id,
description=req.description,
mode=req.mode,
)
await db.flush()
set_parties(session.id, employee_id=employee_id)
await db.commit()
asyncio.create_task(_run_background(session.id))
return success_response(serialize_session(session).__dict__)
@router.get("/sessions/{session_id}/employee", tags=["自动化闭环"])
async def get_session_employee(
session_id: str,
db: AsyncSession = Depends(get_db),
employee_id: str = Depends(get_current_employee_id),
):
"""员工(H5)查看自己会话详情。"""
svc = AutoSessionService(db)
detail = await svc.get_session_detail(session_id)
if detail is None or detail["session"].employee_id != employee_id:
raise AppException(4005, "自动化会话不存在")
return success_response(_detail_payload(detail))
@router.post("/sessions/{session_id}/confirm", tags=["自动化闭环"])
async def confirm_session(
session_id: str,
req: ConfirmRequest,
db: AsyncSession = Depends(get_db),
employee_id: str = Depends(get_current_employee_id),
):
"""员工 H5 二次确认(高危写操作)。"""
svc = AutoSessionService(db)
detail = await svc.get_session_detail(session_id)
if detail is None or detail["session"].employee_id != employee_id:
raise AppException(4005, "自动化会话不存在")
action_id = detail["session"].current_action_id
if not action_id or detail["ticket"] is None:
raise AppException(4004, "当前没有待确认的动作")
if detail["ticket"].channel != "h5":
raise AppException(4004, "该动作需坐席审批,员工无需确认")
executor = ActionExecutor(db)
try:
await executor.resume(
session_id,
action_id,
decision="approve" if req.confirmed else "reject",
note=req.note,
approver_id=employee_id,
)
except AutomationException as e:
raise to_app_exception(e)
await db.commit()
return success_response(_detail_payload(await svc.get_session_detail(session_id)))
@router.post("/sessions/{session_id}/feedback", tags=["自动化闭环"])
async def feedback_session(
session_id: str,
req: ResolveFeedbackRequest,
db: AsyncSession = Depends(get_db),
employee_id: str = Depends(get_current_employee_id),
):
"""员工对处置结果反馈(满意→关单 / 不满意→转人工)。"""
svc = AutoSessionService(db)
try:
session = await svc.resolve_feedback(
session_id, satisfied=req.satisfied, note=req.note
)
except AutomationException as e:
raise to_app_exception(e)
await db.commit()
return success_response(serialize_session(session).__dict__)
# ==========================================================================
# 管理端接口(配置写需 OTP
# ==========================================================================
@router.get("/admin/scenarios", tags=["自动化闭环-管理"])
async def list_scenarios(
db: AsyncSession = Depends(get_db),
):
"""场景配置列表(只读,无需 OTP)。"""
svc = AutoSessionService(db)
configs = await svc.list_scenario_configs()
return success_response([_scenario_payload(c) for c in configs])
@router.put("/admin/scenarios/{scenario_key}", tags=["自动化闭环-管理"])
async def update_scenario(
scenario_key: str,
req: ScenarioConfigUpdate,
db: AsyncSession = Depends(get_db),
_otp: object = Depends(require_high_risk_otp),
):
"""更新场景配置(高危写操作,需 OTP)。"""
svc = AutoSessionService(db)
data = req.model_dump(exclude_unset=True)
config = await svc.upsert_scenario_config(scenario_key, data, operator="admin")
await db.commit()
return success_response(_scenario_payload(config))
@router.get("/admin/rule-versions", tags=["自动化闭环-管理"])
async def list_rule_versions(
scenario_key: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
):
"""规则版本列表。"""
svc = AutoSessionService(db)
versions = await svc.list_rule_versions(scenario_key=scenario_key)
return success_response([_rule_version_payload(v) for v in versions])
@router.get("/admin/metrics", tags=["自动化闭环-管理"])
async def get_metrics(
db: AsyncSession = Depends(get_db),
):
"""自动化看板指标。"""
svc = AutoSessionService(db)
metrics = await svc.metrics()
return success_response(metrics)
# ==========================================================================
# WebSocket:自动化进度专用通道
# ==========================================================================
@ws_router.websocket("/ws/automation/{session_id}")
async def automation_ws_endpoint(websocket: WebSocket, session_id: str) -> None:
"""自动化会话专用 WebSocket(坐席/员工均可连)。
认证:优先 subprotocol bearer.{token},其次 Authorization header
最后 query ?token=。token 需在 agent:token / employee:token 中存在。
"""
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."):
token = subprotocol[7:]
else:
auth_header = websocket.headers.get("Authorization", "")
token = auth_header[7:] if auth_header.startswith("Bearer ") else websocket.query_params.get("token", "")
if not token:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Missing token")
return
# 校验 token(坐席或员工任一即可)
try:
aid = await cache_service.get(f"agent:token:{token}")
eid = await cache_service.get(f"employee:token:{token}") if not aid else None
except Exception as e: # noqa: BLE001
logger.error(f"自动化 WS token 校验失败: {e}")
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Auth unavailable")
return
if not aid and not eid:
await websocket.accept()
await websocket.close(code=WS_CLOSE_UNAUTHORIZED, reason="Invalid token")
return
register_ws(session_id, websocket)
logger.info(f"自动化 WS 连接: session={session_id}")
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
unregister_ws(session_id, websocket)
logger.info(f"自动化 WS 断开: session={session_id}")
except Exception: # noqa: BLE001
unregister_ws(session_id, websocket)
# ==========================================================================
# 辅助函数
# ==========================================================================
async def _run_background(session_id: str) -> None:
"""后台运行编排(独立导入避免循环依赖)。"""
from app.services.automation import run_session_in_background
await run_session_in_background(session_id)
def _session_min(session) -> dict:
"""会话列表最小字段。"""
return {
"id": session.id,
"employee_id": session.employee_id,
"scenario_key": session.scenario_key,
"status": session.status,
"mode": session.mode,
"confidence": session.confidence,
"title": session.title,
"created_at": session.created_at.isoformat() if session.created_at else None,
"updated_at": session.updated_at.isoformat() if session.updated_at else None,
}
def _detail_payload(detail: dict) -> dict:
"""构造会话详情响应 data。"""
session = detail["session"]
actions = detail.get("actions", [])
ticket = detail.get("ticket")
return serialize_session(session, actions=actions, ticket=ticket).__dict__
def _scenario_payload(config) -> dict:
"""场景配置响应。"""
return ScenarioConfigResponse(
id=config.id,
scenario_key=config.scenario_key,
name=config.name,
description=config.description,
enabled=config.enabled,
trigger_conditions=config.trigger_conditions,
actions=config.actions,
approval_strategy=config.approval_strategy,
current_version_id=config.current_version_id,
).model_dump()
def _rule_version_payload(version) -> dict:
"""规则版本响应。"""
return RuleVersionResponse(
id=version.id,
scenario_key=version.scenario_key,
version=version.version,
content=version.content,
status=version.status,
canary_percent=version.canary_percent,
created_by=version.created_by,
remark=version.remark,
created_at=version.created_at.isoformat() if version.created_at else None,
).model_dump()