"""AI 事件调度与调用链编排器。 根据业务事件(消费、备注、任务分配)编排 AI 应用调用链, 确保执行顺序和数据依赖正确。 调用链: - 消费事件(无助教):App3 → App8 → App7 - 消费事件(有助教):App3 → App8 → App7 + App4 → App5 - 备注事件:App6 → App8 - 任务分配事件:App4 → App5(读已有 App8 缓存) 容错策略: - 某步失败记录错误日志,后续应用使用已有缓存继续 - 失败应用写入失败 conversation 记录 - 整条链后台异步执行,不阻塞业务请求 """ from __future__ import annotations import json import logging from typing import Any, Callable, Coroutine from app.ai.bailian_client import BailianClient from app.ai.cache_service import AICacheService from app.ai.conversation_service import ConversationService from app.ai.schemas import CacheTypeEnum logger = logging.getLogger(__name__) class AIDispatcher: """AI 应用调用链编排器。""" def __init__( self, bailian: BailianClient, cache_svc: AICacheService, conv_svc: ConversationService, ) -> None: self.bailian = bailian self.cache_svc = cache_svc self.conv_svc = conv_svc async def handle_consumption_event( self, member_id: int, site_id: int, settle_id: int, assistant_id: int | None = None, ) -> None: """消费事件链:App3 → App8 → App7(+ App4 → App5 如有助教)。""" from app.ai.apps.app3_clue import run as app3_run from app.ai.apps.app4_analysis import run as app4_run from app.ai.apps.app5_tactics import run as app5_run from app.ai.apps.app7_customer import run as app7_run from app.ai.apps.app8_consolidation import run as app8_run context: dict[str, Any] = { "member_id": member_id, "site_id": site_id, "settle_id": settle_id, "user_id": "system", "nickname": "", } # 步骤 1:App3 线索分析 app3_result = await self._run_step("app3_clue", app3_run, context) # 步骤 2:App8 线索整理(需要 App3 的 clues) app8_context = {**context} # 从 App3 结果提取 clues;同时从缓存获取 App6 已有线索 if app3_result: app8_context["app3_clues"] = app3_result.get("clues", []) app8_context["app3_generated_at"] = None # 刚生成,无需时间戳 else: app8_context["app3_clues"] = [] app8_context["app3_generated_at"] = None # 从缓存获取 App6 已有线索 app6_cache = self.cache_svc.get_latest( CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, str(member_id), ) if app6_cache: app6_result_json = app6_cache.get("result_json", {}) if isinstance(app6_result_json, str): try: app6_result_json = json.loads(app6_result_json) except (json.JSONDecodeError, TypeError): app6_result_json = {} app8_context["app6_clues"] = app6_result_json.get("clues", []) app8_context["app6_generated_at"] = app6_cache.get("created_at") else: app8_context["app6_clues"] = [] app8_context["app6_generated_at"] = None await self._run_step("app8_consolidation", app8_run, app8_context) # 步骤 3:App7 客户分析 await self._run_step("app7_customer", app7_run, context) # 步骤 4(可选):如有助教,App4 → App5 if assistant_id is not None: app4_context = {**context, "assistant_id": assistant_id} app4_result = await self._run_step("app4_analysis", app4_run, app4_context) app5_context = { **context, "assistant_id": assistant_id, "app4_result": app4_result or {}, } await self._run_step("app5_tactics", app5_run, app5_context) async def handle_note_event( self, member_id: int, site_id: int, note_id: int, note_content: str, noted_by_name: str, ) -> None: """备注事件链:App6 → App8。""" from app.ai.apps.app6_note import run as app6_run from app.ai.apps.app8_consolidation import run as app8_run context: dict[str, Any] = { "member_id": member_id, "site_id": site_id, "note_id": note_id, "note_content": note_content, "noted_by_name": noted_by_name, "user_id": "system", "nickname": "", } # 步骤 1:App6 备注分析 app6_result = await self._run_step("app6_note", app6_run, context) # 步骤 2:App8 线索整理(需要 App6 的 clues) app8_context: dict[str, Any] = { "member_id": member_id, "site_id": site_id, "user_id": "system", "nickname": "", } if app6_result: app8_context["app6_clues"] = app6_result.get("clues", []) app8_context["app6_generated_at"] = None else: app8_context["app6_clues"] = [] app8_context["app6_generated_at"] = None # 从缓存获取 App3 已有线索 app3_cache = self.cache_svc.get_latest( CacheTypeEnum.APP3_CLUE.value, site_id, str(member_id), ) if app3_cache: app3_result_json = app3_cache.get("result_json", {}) if isinstance(app3_result_json, str): try: app3_result_json = json.loads(app3_result_json) except (json.JSONDecodeError, TypeError): app3_result_json = {} app8_context["app3_clues"] = app3_result_json.get("clues", []) app8_context["app3_generated_at"] = app3_cache.get("created_at") else: app8_context["app3_clues"] = [] app8_context["app3_generated_at"] = None await self._run_step("app8_consolidation", app8_run, app8_context) async def handle_task_assign_event( self, assistant_id: int, member_id: int, site_id: int, task_type: str, ) -> None: """任务分配事件链:App4 → App5(读已有 App8 缓存)。""" from app.ai.apps.app4_analysis import run as app4_run from app.ai.apps.app5_tactics import run as app5_run context: dict[str, Any] = { "assistant_id": assistant_id, "member_id": member_id, "site_id": site_id, "task_type": task_type, "user_id": "system", "nickname": "", } # 步骤 1:App4 关系分析 app4_result = await self._run_step("app4_analysis", app4_run, context) # 步骤 2:App5 话术参考 app5_context = { **context, "app4_result": app4_result or {}, } await self._run_step("app5_tactics", app5_run, app5_context) async def _run_chain( self, chain: list[tuple[str, Callable[..., Coroutine], dict]], ) -> None: """串行执行调用链,某步失败记录日志后继续。 Args: chain: [(app_name, run_func, context), ...] 的列表 """ for app_name, run_func, ctx in chain: await self._run_step(app_name, run_func, ctx) async def _run_step( self, app_name: str, run_func: Callable[..., Coroutine], context: dict, ) -> dict | None: """执行单个应用步骤,失败时记录日志并写入失败 conversation。 Returns: 应用返回结果,失败时返回 None """ try: result = await run_func( context, self.bailian, self.cache_svc, self.conv_svc, ) logger.info("调用链步骤成功: %s", app_name) return result except Exception: logger.exception("调用链步骤失败: %s", app_name) # 写入失败 conversation 记录 try: site_id = context.get("site_id", 0) conv_id = self.conv_svc.create_conversation( user_id="system", nickname="", app_id=app_name, site_id=site_id, source_context={"error": True, "chain_step": app_name}, ) self.conv_svc.add_message( conversation_id=conv_id, role="system", content=f"调用链步骤 {app_name} 执行失败", ) except Exception: logger.exception("写入失败 conversation 记录也失败: %s", app_name) return None def _create_ai_event_handlers(dispatcher: AIDispatcher) -> dict[str, Callable]: """创建 AI 事件处理器,用于注册到 trigger_scheduler。 每个处理器从 payload 提取参数,通过 asyncio.create_task 后台执行, 不阻塞同步的 fire_event 调用。 Returns: {event_job_type: handler_func} 映射 """ import asyncio def _get_or_create_loop() -> asyncio.AbstractEventLoop: """获取当前事件循环,兼容同步调用场景。""" try: return asyncio.get_running_loop() except RuntimeError: return asyncio.new_event_loop() def handle_consumption_settled(payload: dict | None = None, **_kw: Any) -> None: """消费结算事件处理器(同步入口,内部异步执行)。""" if not payload: logger.warning("consumption_settled 事件缺少 payload") return loop = _get_or_create_loop() loop.create_task( dispatcher.handle_consumption_event( member_id=payload["member_id"], site_id=payload["site_id"], settle_id=payload["settle_id"], assistant_id=payload.get("assistant_id"), ) ) def handle_note_created(payload: dict | None = None, **_kw: Any) -> None: """备注创建事件处理器。""" if not payload: logger.warning("note_created 事件缺少 payload") return loop = _get_or_create_loop() loop.create_task( dispatcher.handle_note_event( member_id=payload["member_id"], site_id=payload["site_id"], note_id=payload["note_id"], note_content=payload.get("note_content", ""), noted_by_name=payload.get("noted_by_name", ""), ) ) def handle_task_assigned(payload: dict | None = None, **_kw: Any) -> None: """任务分配事件处理器。""" if not payload: logger.warning("task_assigned 事件缺少 payload") return loop = _get_or_create_loop() loop.create_task( dispatcher.handle_task_assign_event( assistant_id=payload["assistant_id"], member_id=payload["member_id"], site_id=payload["site_id"], task_type=payload.get("task_type", ""), ) ) return { "ai_consumption_settled": handle_consumption_settled, "ai_note_created": handle_note_created, "ai_task_assigned": handle_task_assigned, } def register_ai_handlers(dispatcher: AIDispatcher) -> None: """将 AI 事件处理器注册到 trigger_scheduler。 在 FastAPI lifespan 中调用,将三个 AI 事件处理器 注册为 trigger_scheduler 的 job handler。 """ from app.services.trigger_scheduler import register_job handlers = _create_ai_event_handlers(dispatcher) for job_type, handler in handlers.items(): register_job(job_type, handler) logger.info("已注册 AI 事件处理器: %s", job_type)