# ============================================================================= # 企微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, ResolveFeedbackRequest, ScenarioConfigResponse, ScenarioConfigUpdate, SessionResponse, ApprovalDecisionRequest, ConfirmRequest, RuleVersionResponse, ResolveFeedbackRequest, TakeoverRequest, AutoMetricsResponse, serialize_action, serialize_approval, serialize_information_item, serialize_session, PauseRequest, ResumeRequest, CorrectRequest, SupplementRequest, AgentResumeRequest, AgentCloseRequest, BatchCorrectRequest, BatchCorrectResponse, UndoCorrectionResponse, CorrectionHistoryResponse, VersionChainResponse, VersionDiffResponse, CompressionLogItem, CompressionLogListResponse, VersionDiffRequest, ) from app.services.automation import ( ActionExecutor, AutoSessionService, AutomationException, InformationItemService, to_app_exception, ) from app.services.automation.correction_service import CorrectionService from app.services.automation.snapshot_service import SnapshotService from app.services.automation.context_compressor import ContextCompressor from app.models.automation import ContextCompression 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]) # --- 复杂场景重构:暂停会话相关端点(必须在 /sessions/{session_id} 之前注册)--- @router.get("/sessions/paused", tags=["自动化闭环"]) async def list_paused_sessions( employee_id: str = Query(..., description="员工ID"), db: AsyncSession = Depends(get_db), _agent: Agent = Depends(get_current_agent), ): """获取员工的暂停会话列表。""" svc = AutoSessionService(db) paused_list = await svc.list_paused_sessions(employee_id) return success_response(paused_list) @router.post("/sessions/resume", tags=["自动化闭环"]) async def resume_session_select( req: ResumeRequest, db: AsyncSession = Depends(get_db), employee_id: str = Depends(get_current_employee_id), ): """恢复暂停会话(员工端,支持多任务选择)。 若未指定 session_id 且存在多个暂停会话,返回列表供前端选择。 """ svc = AutoSessionService(db) try: result = await svc.resume_session(employee_id, req.session_id) except AutomationException as e: raise to_app_exception(e) await db.commit() # 多任务选择场景 if result.get("need_select"): return success_response({ "need_select": True, "paused_list": result["paused_list"], }) session = result["session"] if session is None: return success_response({"need_select": False, "session": None, "message": "没有暂停的任务"}) return success_response({ "need_select": False, "session": serialize_session(session).__dict__, "resume_point": result.get("resume_point"), "pending_items": result.get("pending_items", []), }) @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__) # --- 复杂场景重构:员工端暂停/恢复/更正/补充/信息项端点 --- @router.post("/sessions/{session_id}/pause", tags=["自动化闭环"]) async def pause_session( session_id: str, req: PauseRequest, db: AsyncSession = Depends(get_db), employee_id: str = Depends(get_current_employee_id), ): """暂停会话(员工/坐席均可)。""" svc = AutoSessionService(db) try: session = await svc.pause_session(session_id, reason=req.reason) except AutomationException as e: raise to_app_exception(e) await db.commit() return success_response(serialize_session(session).__dict__) @router.post("/sessions/{session_id}/resume", tags=["自动化闭环"]) async def resume_session_by_id( session_id: str, db: AsyncSession = Depends(get_db), employee_id: str = Depends(get_current_employee_id), ): """恢复指定会话(员工端,选中某个暂停会话后调用)。""" svc = AutoSessionService(db) try: result = await svc.resume_session(employee_id, session_id) except AutomationException as e: raise to_app_exception(e) await db.commit() session = result["session"] return success_response({ "session": serialize_session(session).__dict__ if session else None, "resume_point": result.get("resume_point"), "pending_items": result.get("pending_items", []), }) @router.post("/sessions/{session_id}/correct", tags=["自动化闭环"]) async def correct_info( session_id: str, req: CorrectRequest, db: AsyncSession = Depends(get_db), employee_id: str = Depends(get_current_employee_id), ): """信息更正。""" svc = AutoSessionService(db) try: item = await svc.correct_info( session_id, req.field, req.new_value, req.old_value ) except AutomationException as e: raise to_app_exception(e) await db.commit() return success_response(serialize_information_item(item).__dict__) @router.post("/sessions/{session_id}/supplement", tags=["自动化闭环"]) async def supplement_info( session_id: str, req: SupplementRequest, db: AsyncSession = Depends(get_db), employee_id: str = Depends(get_current_employee_id), ): """信息补充。""" svc = AutoSessionService(db) try: item = await svc.supplement_info(session_id, req.field, req.value) except AutomationException as e: raise to_app_exception(e) await db.commit() return success_response(serialize_information_item(item).__dict__) @router.get("/sessions/{session_id}/info-items", tags=["自动化闭环"]) async def get_info_items( session_id: str, db: AsyncSession = Depends(get_db), _agent: Agent = Depends(get_current_agent), ): """获取会话信息项列表。""" info_svc = InformationItemService(db) items = await info_svc.get_items(session_id) return success_response([serialize_information_item(item).__dict__ for item in items]) # --- 复杂场景重构:坐席端代恢复/关闭端点 --- @router.post("/sessions/{session_id}/agent-resume", tags=["自动化闭环"]) async def agent_resume_session( session_id: str, req: AgentResumeRequest, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """坐席代恢复暂停会话。""" svc = AutoSessionService(db) try: session = await svc.agent_resume( session_id, current_agent.user_id, req.note ) except AutomationException as e: raise to_app_exception(e) await db.commit() return success_response(serialize_session(session).__dict__) @router.post("/sessions/{session_id}/agent-close", tags=["自动化闭环"]) async def agent_close_session( session_id: str, req: AgentCloseRequest, db: AsyncSession = Depends(get_db), current_agent: Agent = Depends(get_current_agent), ): """坐席关闭暂停会话。""" svc = AutoSessionService(db) try: session = await svc.agent_close( session_id, current_agent.user_id, req.note ) except AutomationException as e: raise to_app_exception(e) await db.commit() return success_response(serialize_session(session).__dict__) # --- 复杂场景重构第二阶段:P2/P3 端点 --- @router.post("/sessions/{session_id}/batch-correct", tags=["自动化闭环"]) async def batch_correct_info( session_id: str, req: BatchCorrectRequest, db: AsyncSession = Depends(get_db), employee_id: str = Depends(get_current_employee_id), ): """批量更正信息项(原子事务,失败整体回滚)。""" svc = CorrectionService(db) try: result = await svc.batch_correct( session_id, req.corrections, req.reason ) except AutomationException: raise to_app_exception(AutomationException(4019, "批量更正失败,已回滚")) except Exception as e: logger.error(f"批量更正失败: {e}") raise to_app_exception(AutomationException(4019, f"批量更正失败: {e}")) await db.commit() return success_response(result.to_dict()) @router.post("/sessions/{session_id}/undo-correction", tags=["自动化闭环"]) async def undo_correction( session_id: str, db: AsyncSession = Depends(get_db), employee_id: str = Depends(get_current_employee_id), ): """撤销最近一次更正。""" svc = CorrectionService(db) try: result = await svc.undo_correction(session_id) except ValueError as e: if "超限" in str(e): raise to_app_exception(AutomationException(4018, str(e))) raise AppException(4004, str(e)) await db.commit() # 计算剩余撤销次数:查询当前已撤销的快照数量 from app.config import settings from sqlalchemy import func, select as _select from app.models.automation import InformationSnapshot undone_stmt = ( _select(func.count(InformationSnapshot.id)) .where( InformationSnapshot.session_id == session_id, InformationSnapshot.is_undone == True, # noqa: E712 ) ) undone_count = (await db.execute(undone_stmt)).scalar() or 0 remaining = settings.max_undo_count - undone_count result["remaining_undo_count"] = max(0, remaining) return success_response(result) @router.get("/sessions/{session_id}/correction-history", tags=["自动化闭环"]) async def get_correction_history( session_id: str, db: AsyncSession = Depends(get_db), _agent: Agent = Depends(get_current_agent), ): """获取更正历史。""" svc = CorrectionService(db) history = await svc.get_correction_history(session_id) return success_response({"history": history}) @router.get("/sessions/{session_id}/version-chain/{item_name}", tags=["自动化闭环"]) async def get_version_chain( session_id: str, item_name: str, db: AsyncSession = Depends(get_db), _agent: Agent = Depends(get_current_agent), ): """获取信息项版本链。""" svc = CorrectionService(db) chain = await svc.get_version_chain(session_id, item_name) return success_response({"item_key": item_name, "chain": chain}) @router.post("/sessions/{session_id}/version-diff/{item_name}", tags=["自动化闭环"]) async def get_version_diff( session_id: str, item_name: str, req: VersionDiffRequest, db: AsyncSession = Depends(get_db), _agent: Agent = Depends(get_current_agent), ): """版本对比。""" svc = CorrectionService(db) diff = await svc.get_version_diff(session_id, item_name, req.v1, req.v2) return success_response(diff) @router.get("/sessions/{session_id}/compression-logs", tags=["自动化闭环"]) async def get_compression_logs( session_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), _agent: Agent = Depends(get_current_agent), ): """获取上下文压缩日志。""" from sqlalchemy import func, select stmt = ( select(ContextCompression) .where(ContextCompression.session_id == session_id) .order_by(ContextCompression.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) result = await db.execute(stmt) logs = result.scalars().all() count_stmt = select(func.count()).select_from(ContextCompression).where( ContextCompression.session_id == session_id ) total = (await db.execute(count_stmt)).scalar() or 0 return success_response({ "logs": [ { "id": log.id, "session_id": log.session_id, "tokens_before": log.tokens_before, "tokens_after": log.tokens_after, "compression_ratio": float(log.compression_ratio), "task_node": log.task_node, "duration_ms": log.duration_ms, "compression_level": log.compression_level, "summary": log.summary, "created_at": log.created_at.isoformat() if log.created_at else None, } for log in logs ], "total": total, }) # ========================================================================== # 管理端接口(配置写需 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, "paused_at": session.paused_at.isoformat() if session.paused_at else None, "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()