# ============================================================================= # 企微IT智能服务台 — 阶段5 自动化 会话编排服务 # ============================================================================= # 说明:自动化会话的编排中枢,串联「意图识别 → 场景校验 → 终端映射 → # 动作计划生成 → 执行引擎」。同时提供会话 CRUD、转人工、结果反馈、 # 静默关单等接口。 # # 编排在后台任务中运行(run_session_in_background),API 创建会话后立即返回, # 进度通过 WS 实时推送。 # ============================================================================= from __future__ import annotations import logging from datetime import datetime, timezone from typing import Any, Dict, List, Optional from sqlalchemy import select from app.config import settings from app.constants import AutomationErrorCode from app.database import _get_session_factory from app.models.automation import ( ApprovalTicket, AutoAction, AutoSession, ScenarioConfig, ) from app.services.automation.approval import ApprovalService from app.services.automation.exception_handler import AutomationException from app.services.automation.executor import ActionExecutor from app.services.automation.intent_router import IntentRouter from app.services.automation.mapping_resolver import MappingResolver from app.services.automation.progress_publisher import ( cancel_silent_close, publish_progress, publish_takeover, ) from app.services.automation import DEFAULT_SCENARIO_CONFIGS logger = logging.getLogger(__name__) # 需要终端映射的场景 _SCENARIOS_NEED_TERMINAL = {"virus_dispose", "terminal_locate"} class AutoSessionService: """自动化会话编排服务。""" def __init__(self, db: Any, redis: Any = None, audit: Any = None): self.db = db self.redis = redis self.audit = audit # -------------------------------------------------------------------------- # 会话 CRUD # -------------------------------------------------------------------------- async def create_session( self, conversation_id: Optional[str], employee_id: str, description: str, mode: str = "real_exec", ) -> AutoSession: """创建自动化会话(状态=created)。""" session = AutoSession( conversation_id=conversation_id, employee_id=employee_id, mode=mode, title=(description or "")[:200], status="created", meta={"description": description}, ) self.db.add(session) await self.db.flush() return session async def get_session(self, session_id: str) -> Optional[AutoSession]: """按 ID 取会话。""" stmt = select(AutoSession).where(AutoSession.id == session_id) return (await self.db.execute(stmt)).scalar_one_or_none() async def list_sessions( self, employee_id: Optional[str] = None, agent_id: Optional[str] = None, status: Optional[str] = None, page: int = 1, page_size: int = 50, ) -> List[AutoSession]: """列出会话(支持过滤分页)。""" stmt = select(AutoSession) if employee_id: stmt = stmt.where(AutoSession.employee_id == employee_id) if agent_id: stmt = stmt.where(AutoSession.agent_id == agent_id) if status: stmt = stmt.where(AutoSession.status == status) stmt = stmt.order_by(AutoSession.created_at.desc()) stmt = stmt.limit(page_size).offset((page - 1) * page_size) return list((await self.db.execute(stmt)).scalars().all()) async def get_session_detail( self, session_id: str ) -> Optional[Dict[str, Any]]: """取会话详情(含动作列表与当前待决审批单)。""" session = await self.get_session(session_id) 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) ticket = None if session.current_action_id: tstmt = select(ApprovalTicket).where( ApprovalTicket.action_id == session.current_action_id, ApprovalTicket.status == "pending", ) ticket = (await self.db.execute(tstmt)).scalar_one_or_none() return {"session": session, "actions": actions, "ticket": ticket} # -------------------------------------------------------------------------- # 编排主流程 # -------------------------------------------------------------------------- async def start(self, session_id: str) -> None: """编排:意图识别 → 场景校验 → 映射 → 计划 → 执行。""" session = await self.get_session(session_id) if session is None: logger.warning(f"start 会话不存在: {session_id}") return if session.status != "created": logger.info(f"会话已启动过,跳过: {session_id} status={session.status}") return session.status = "running" await self.db.flush() await publish_progress(session.id, "start", "开始自动化处置") description = (session.meta or {}).get("description", "") # 1. 意图识别 router = IntentRouter(self.db, audit=self.audit) try: intent = await router.detect(description, session.employee_id) except Exception as e: # noqa: BLE001 intent = {"scenario_key": None, "confidence": 0.0, "error": str(e)} session.scenario_key = intent.get("scenario_key") session.confidence = float(intent.get("confidence") or 0.0) session.intent = intent await self.db.flush() await publish_progress( session.id, "intent", f"识别场景: {session.scenario_key or '未知'}(置信度 {session.confidence:.2f})", ) # 2. 置信度门槛 → 低置信度转人工 thresholds = settings.get_automation_thresholds() confidence_min = float(thresholds.get("confidence_min", 0.6)) if not session.scenario_key or session.confidence < confidence_min: await self._handoff( session, f"意图识别置信度不足({session.confidence:.2f}),转人工" ) return # 3. 场景配置校验 scenario = await self._get_scenario_config(session.scenario_key) if scenario is None or not scenario.enabled: await self._handoff( session, f"场景未启用或未配置: {session.scenario_key}" ) return # 4. 终端映射(按需) mapping: Dict[str, Any] = {} if session.scenario_key in _SCENARIOS_NEED_TERMINAL: resolver = MappingResolver(self.db, audit=self.audit) mapping = await resolver.resolve(session.employee_id, session.scenario_key) if ( session.scenario_key == "virus_dispose" and not mapping.get("client_ids") ): await self._handoff(session, "未解析到目标终端,转人工") return session.meta = {**(session.meta or {}), "mapping": mapping} await self.db.flush() # 5. 生成动作计划 plan = self._build_actions(scenario, mapping) for idx, item in enumerate(plan): action = AutoAction(session_id=session.id, action_index=idx, **item) self.db.add(action) await self.db.flush() await publish_progress( session.id, "plan_ready", f"已生成处置方案(共 {len(plan)} 步)" ) # 6. 执行引擎 executor = ActionExecutor(self.db, self.redis, audit=self.audit) await executor.run(session_id) def _build_actions( self, scenario: ScenarioConfig, mapping: Dict[str, Any] ) -> List[Dict[str, Any]]: """从场景配置(或默认模板)生成动作计划。""" plan_items = scenario.actions if not plan_items: default = DEFAULT_SCENARIO_CONFIGS.get(scenario.scenario_key, {}) plan_items = default.get("actions", []) result: List[Dict[str, Any]] = [] for it in plan_items: params: Dict[str, Any] = dict(it.get("params") or {}) confirm_channel = it.get("confirm_channel") if mapping.get("client_ids"): params.setdefault("client_ids", mapping["client_ids"]) if confirm_channel: params["confirm_channel"] = confirm_channel result.append( { "action_type": it.get("action_type", ""), "adapter": it.get("adapter", "internal"), "risk_level": it.get("risk_level", "read"), "title": it.get("title", it.get("action_type", "")), "description": it.get("description", it.get("title", "")), "payload": params, } ) return result async def _get_scenario_config( self, scenario_key: str ) -> Optional[ScenarioConfig]: """加载场景配置(DB 优先,缺失则用默认模板的开关)。""" stmt = select(ScenarioConfig).where( ScenarioConfig.scenario_key == scenario_key ) config = (await self.db.execute(stmt)).scalar_one_or_none() if config is not None: return config # DB 无记录 → 用默认模板(默认启用) default = DEFAULT_SCENARIO_CONFIGS.get(scenario_key) if default is None: return None return ScenarioConfig( scenario_key=scenario_key, name=default.get("name", scenario_key), description=default.get("description", ""), enabled=default.get("enabled", True), trigger_conditions=default.get("trigger_conditions"), actions=default.get("actions"), approval_strategy=default.get("approval_strategy"), ) async def _handoff(self, session: AutoSession, reason: str) -> None: """置为转人工并推送事件。""" session.status = "handoff" session.closed_by = "system(auto)" await self.db.flush() await publish_takeover(session.id, reason) # -------------------------------------------------------------------------- # 转人工 / 反馈 / 关单 # -------------------------------------------------------------------------- async def takeover( self, session_id: str, agent_id: str, note: Optional[str] = None ) -> AutoSession: """转人工接管。""" session = await self.get_session(session_id) if session is None: raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND) session.status = "handoff" session.agent_id = agent_id session.closed_by = agent_id await self.db.flush() cancel_silent_close(session_id) await publish_takeover(session.id, f"坐席 {agent_id} 接管:{note or ''}") return session async def resolve_feedback( self, session_id: str, satisfied: bool, note: Optional[str] = None ) -> AutoSession: """员工处置结果反馈。 满意 → 关单;不满意 → 转人工。 """ session = await self.get_session(session_id) if session is None: raise AutomationException(AutomationErrorCode.SESSION_NOT_FOUND) cancel_silent_close(session_id) if satisfied: session.status = "closed" session.closed_by = session.employee_id else: session.status = "handoff" session.closed_by = "employee(reject)" await publish_takeover(session.id, f"员工不满意,转人工:{note or ''}") await self.db.flush() return session async def auto_close(self, session_id: str) -> None: """静默关单(仅 resolved 态可关)。""" session = await self.get_session(session_id) if session is None: return if session.status != "resolved": return session.status = "closed" session.closed_by = "system(auto)" await self.db.flush() # -------------------------------------------------------------------------- # 场景配置管理(管理端) # -------------------------------------------------------------------------- async def list_scenario_configs(self) -> List[ScenarioConfig]: """列出全部场景配置。""" stmt = select(ScenarioConfig).order_by(ScenarioConfig.scenario_key) return list((await self.db.execute(stmt)).scalars().all()) async def upsert_scenario_config( self, scenario_key: str, data: Dict[str, Any], operator: str = "" ) -> ScenarioConfig: """更新或创建场景配置,并快照为规则版本。""" from app.models.automation import RuleVersion stmt = select(ScenarioConfig).where( ScenarioConfig.scenario_key == scenario_key ) config = (await self.db.execute(stmt)).scalar_one_or_none() if config is None: config = ScenarioConfig(scenario_key=scenario_key) self.db.add(config) if data.get("name") is not None: config.name = data["name"] if data.get("description") is not None: config.description = data["description"] if data.get("enabled") is not None: config.enabled = data["enabled"] if data.get("trigger_conditions") is not None: config.trigger_conditions = data["trigger_conditions"] if data.get("actions") is not None: config.actions = data["actions"] if data.get("approval_strategy") is not None: config.approval_strategy = data["approval_strategy"] await self.db.flush() # 快照为规则版本 last = ( await self.db.execute( select(RuleVersion.version) .where(RuleVersion.scenario_key == scenario_key) .order_by(RuleVersion.version.desc()) .limit(1) ) ).scalar_one_or_none() new_version = (last or 0) + 1 snapshot = RuleVersion( scenario_key=scenario_key, version=new_version, content={ "actions": config.actions, "approval_strategy": config.approval_strategy, "trigger_conditions": config.trigger_conditions, }, status="published", canary_percent=100, created_by=operator or "admin", remark=f"更新场景配置至 v{new_version}", ) self.db.add(snapshot) await self.db.flush() config.current_version_id = snapshot.id await self.db.flush() return config async def list_rule_versions( self, scenario_key: Optional[str] = None ) -> List[RuleVersion]: """列出规则版本。""" stmt = select(RuleVersion) if scenario_key: stmt = stmt.where(RuleVersion.scenario_key == scenario_key) stmt = stmt.order_by(RuleVersion.created_at.desc()) return list((await self.db.execute(stmt)).scalars().all()) async def metrics(self) -> Dict[str, Any]: """汇总看板指标。""" from sqlalchemy import func total = ( await self.db.execute(select(func.count(AutoSession.id))) ).scalar() or 0 resolved = ( await self.db.execute( select(func.count(AutoSession.id)).where( AutoSession.status == "resolved" ) ) ).scalar() or 0 handoff = ( await self.db.execute( select(func.count(AutoSession.id)).where( AutoSession.status == "handoff" ) ) ).scalar() or 0 error = ( await self.db.execute( select(func.count(AutoSession.id)).where( AutoSession.status == "error" ) ) ).scalar() or 0 auto_actions = ( await self.db.execute( select(func.count(AutoAction.id)).where( AutoAction.status == "success", AutoAction.risk_level.in_(["read", "low"]), ) ) ).scalar() or 0 approval_actions = ( await self.db.execute( select(func.count(AutoAction.id)).where( AutoAction.risk_level == "high" ) ) ).scalar() or 0 by_scenario_rows = ( await self.db.execute( select(AutoSession.scenario_key, func.count(AutoSession.id)).group_by( AutoSession.scenario_key ) ) ).all() by_scenario = {k: v for k, v in by_scenario_rows if k} return { "total_sessions": total, "resolved_sessions": resolved, "handoff_sessions": handoff, "error_sessions": error, "auto_executed_actions": auto_actions, "approval_required_actions": approval_actions, "by_scenario": by_scenario, } # -------------------------------------------------------------------------- # 后台编排入口 # -------------------------------------------------------------------------- async def run_session_in_background(session_id: str) -> None: """后台运行会话编排(独立 DB 会话,结束时提交/回滚)。""" factory = _get_session_factory() async with factory() as db: svc = AutoSessionService(db) try: await svc.start(session_id) await db.commit() except AutomationException as e: await db.rollback() logger.warning(f"编排业务异常 session={session_id}: {e.message}") # 标记转人工 try: session = await svc.get_session(session_id) if session and session.status not in ("closed", "handoff"): session.status = "handoff" session.closed_by = "system(auto)" await db.commit() except Exception: # noqa: BLE001 await db.rollback() except Exception as e: # noqa: BLE001 await db.rollback() logger.error(f"编排未预期异常 session={session_id}: {e}") try: session = await svc.get_session(session_id) if session and session.status not in ("closed", "handoff"): session.status = "handoff" session.closed_by = "system(auto)" await db.commit() except Exception: # noqa: BLE001 await db.rollback()