Files
wecom_it_smart_desk/backend/app/services/automation/executor.py
T

286 lines
12 KiB
Python

# =============================================================================
# 企微IT智能服务台 — 阶段5 自动化 动作执行引擎
# =============================================================================
# 说明:按场景动作计划顺序执行,落实「分级执行」策略:
# - read / low 风险 + real_exec 模式 → 自动执行
# - high 风险 / plan_only 模式 → 挂起并生成审批单(坐席审批 或 员工 H5 确认)
# 执行中任一动作失败 → 触发回滚补偿 + 转人工接管。
# 全部成功 → 处置成功(resolved),调度静默关单。
#
# 设计要点:
# - 主循环 run() 在遇到首个「需审批」动作时挂起并返回,等待 resume() 续跑
# - resume() 更新审批单后再次调用 run() 继续后续动作(支持多步审批)
# =============================================================================
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from app.constants import (
AUTOMATION_AUTO_EXECUTABLE_RISKS,
AUTOMATION_SESSION_TERMINAL_STATES,
AutomationErrorCode,
)
from app.database import _get_session_factory
from app.integrations.base import BaseClientError
from app.integrations.factory import (
build_dify_client,
build_ehr_client,
build_huorong_client,
build_lianruan_client,
)
from app.models.automation import AutoAction, AutoSession
from app.services.automation.action_registry import ActionContext, get_handler
from app.services.automation.approval import ApprovalService
from app.services.automation.exception_handler import AutomationException
from app.services.automation.progress_publisher import (
cancel_silent_close,
publish_progress,
publish_resolved,
publish_takeover,
schedule_silent_close,
)
from app.services.automation.rollback import RollbackService
logger = logging.getLogger(__name__)
# 静默关单默认 TTL(秒):处置成功后 10 分钟内员工无异议自动关单
SILENT_CLOSE_TTL = 600
class ActionExecutor:
"""动作执行引擎。"""
def __init__(self, db: Any, redis: Any = None, audit: Any = None):
self.db = db
self.redis = redis
self.audit = audit
self.approval = ApprovalService(db)
self.rollback = RollbackService(db)
# --------------------------------------------------------------------------
# 数据加载
# --------------------------------------------------------------------------
async def _load(self, session_id: str):
"""加载会话及其动作(按动作顺序排序)。"""
stmt = select(AutoSession).where(AutoSession.id == session_id)
session = (await self.db.execute(stmt)).scalar_one_or_none()
if session is None:
return None, []
act_stmt = select(AutoAction).where(AutoAction.session_id == session_id)
actions = list((await self.db.execute(act_stmt)).scalars().all())
actions.sort(key=lambda a: a.action_index)
return session, actions
async def _build_clients(self) -> Dict[str, Any]:
"""构建外部客户端(缺失时置 None,不阻断主流程)。"""
clients: Dict[str, Any] = {}
for name, builder in (
("huorong", build_huorong_client),
("lianruan", build_lianruan_client),
("ehr", build_ehr_client),
("dify", build_dify_client),
):
try:
clients[name] = await builder(self.db, audit=self.audit) if name != "dify" else await builder(audit=self.audit)
except Exception as e: # noqa: BLE001
logger.warning(f"构建外部客户端失败 {name}: {e}")
clients[name] = None
return clients
# --------------------------------------------------------------------------
# 主循环
# --------------------------------------------------------------------------
async def run(self, session_id: str) -> None:
"""执行会话动作计划(遇到审批闸门挂起返回,等待 resume)。"""
session, actions = await self._load(session_id)
if session is None:
logger.warning(f"executor.run 会话不存在: {session_id}")
return
if session.status in AUTOMATION_SESSION_TERMINAL_STATES:
logger.info(f"会话已终态,跳过执行: {session_id} status={session.status}")
return
mapping = (session.meta or {}).get("mapping") or {}
clients = await self._build_clients()
for action in actions:
if action.status in ("success", "failed", "rejected", "skipped"):
continue
# 已批准 → 直接执行
if action.status == "approved":
await self._do_execute(session, action, mapping, clients)
continue
# 待决(pending / await_approval)→ 判断是否需审批闸门
needs_gate = (
action.risk_level not in AUTOMATION_AUTO_EXECUTABLE_RISKS
) or (session.mode == "plan_only")
if needs_gate:
channel = (action.payload or {}).get("confirm_channel") or "agent"
if session.mode == "plan_only":
channel = "agent" # 方案预览阶段统一走坐席确认
ticket = await self.approval.ensure_ticket(
action, channel=channel, reason=action.description
)
action.status = "await_approval"
session.status = "paused"
session.current_action_id = action.id
await self.db.flush()
await publish_action_required(session.id, action, ticket)
return # 暂停,等待审批/确认后续跑
# 可自动执行
await self._do_execute(session, action, mapping, clients)
# 全部动作处理完毕
await self._finish_success(session)
async def _do_execute(
self, session: AutoSession, action: AutoAction, mapping: dict, clients: dict
) -> None:
"""执行单个动作,处理成功/失败分支。"""
handler = get_handler(action.action_type)
if handler is None:
action.status = "failed"
action.error = f"未注册的动作类型: {action.action_type}"
await self.db.flush()
await self._on_action_failed(
session,
action,
AutomationException(AutomationErrorCode.CONFIG_ERROR, action.error),
)
return
ctx = ActionContext(
db=self.db,
session=session,
action=action,
mapping=mapping,
clients=clients,
employee_id=session.employee_id,
audit=self.audit,
)
try:
result = await handler.execute(ctx)
action.status = "success"
action.result = result
await self.db.flush()
await publish_progress(
session.id, "action_done", f"动作完成:{action.title}", action.id
)
except BaseClientError as e:
action.status = "failed"
action.error = str(e)
await self.db.flush()
await self._on_action_failed(
session,
action,
AutomationException(AutomationErrorCode.EXTERNAL_CALL_FAILED, str(e)),
)
except AutomationException as e:
action.status = "failed"
action.error = e.message
await self.db.flush()
await self._on_action_failed(session, action, e)
except Exception as e: # noqa: BLE001
action.status = "failed"
action.error = str(e)
await self.db.flush()
await self._on_action_failed(
session,
action,
AutomationException(AutomationErrorCode.EXTERNAL_CALL_FAILED, str(e)),
)
async def _on_action_failed(
self, session: AutoSession, action: AutoAction, exc: AutomationException
) -> None:
"""动作失败:回滚已执行动作 + 转人工接管。"""
try:
await self.rollback.compensate(session, action)
except Exception as e: # noqa: BLE001
logger.warning(f"回滚补偿异常 session={session.id}: {e}")
session.status = "handoff"
session.closed_by = "system(auto)"
await self.db.flush()
await publish_error(session.id, exc.code, exc.message)
await publish_takeover(session.id, f"动作失败转人工:{exc.message}")
async def _finish_success(self, session: AutoSession) -> None:
"""全部动作成功:置 resolved + 调度静默关单。"""
session.status = "resolved"
session.resolved_at = datetime.now(timezone.utc)
session.current_action_id = None
await self.db.flush()
await publish_resolved(session.id, "处置已完成,等待您确认")
schedule_silent_close(session.id, SILENT_CLOSE_TTL, on_expire=self._auto_close)
# --------------------------------------------------------------------------
# 审批/确认后续跑
# --------------------------------------------------------------------------
async def resume(
self,
session_id: str,
action_id: str,
decision: str,
note: Optional[str],
approver_id: Optional[str],
) -> None:
"""审批/确认结果回来后,更新审批单并续跑计划。"""
session, actions = await self._load(session_id)
if session is None:
return
if session.status in AUTOMATION_SESSION_TERMINAL_STATES:
return
ticket = await self.approval.get_pending_for_action(action_id)
if ticket is not None:
await self.approval.decide(ticket.id, decision, note, approver_id)
action = next((a for a in actions if a.id == action_id), None)
if action is None:
return
if decision == "approve":
action.status = "approved"
action.approved_by = approver_id
action.approved_at = datetime.now(timezone.utc)
await self.db.flush()
# 续跑主循环(会继续执行已批准动作及后续动作)
await self.run(session_id)
else:
action.status = "rejected"
session.status = "handoff"
session.closed_by = approver_id
await self.db.flush()
cancel_silent_close(session_id)
await publish_takeover(
session.id, f"审批驳回转人工:{note or action.title}"
)
# --------------------------------------------------------------------------
# 静默关单回调(独立会话,避免长事务)
# --------------------------------------------------------------------------
async def _auto_close(self, session_id: str) -> None:
"""静默关单:仅在会话仍为 resolved 时自动关单。"""
factory = _get_session_factory()
async with factory() as db:
stmt = select(AutoSession).where(AutoSession.id == session_id)
session = (await db.execute(stmt)).scalar_one_or_none()
if session is None:
return
if session.status != "resolved":
return # 已被接管/关单/反馈
session.status = "closed"
session.closed_by = "system(auto)"
await db.commit()
await publish_progress(session_id, "auto_closed", "已静默关单")