"""AI 事件调度与调用链编排器。 根据业务事件(消费、备注、任务分配、DWS 完成)编排 AI 应用调用链, 确保执行顺序和数据依赖正确。 关键设计: - 所有入口均为 async def,无 asyncio.run() / new_event_loop() - 后台任务通过 asyncio.create_task() 发起 - 超时通过 asyncio.wait_for() 控制 - 每步调用前依次检查:熔断→限流→预算 - 调用链某步失败不中断后续步骤 - prompt 拼装委托给 app.ai.prompts 模块(含真实业务数据) 调用链: - 消费事件(无助教):App3 → App8 → App7 - 消费事件(有助教):App3 → App8 → App7 + App4 → App5 - 备注事件:App6 → App8 - 任务分配事件:App4 → App5 - DWS 完成事件:App2 预生成(8 个时间维度) """ from __future__ import annotations import asyncio import json import logging import time from dataclasses import dataclass, field from datetime import date from typing import Any, Callable from app.ai.budget_tracker import BudgetTracker from app.ai.cache_service import AICacheService from app.ai.circuit_breaker import CircuitBreaker, CircuitState from app.ai.config import AIConfig from app.ai.conversation_service import ConversationService from app.ai.dashscope_client import DashScopeClient from app.ai.prompts import ( build_app2_prompt, build_app2a_area_prompt, build_app3_prompt, build_app4_prompt, build_app5_prompt, build_app6_prompt, build_app7_prompt, build_app8_prompt, ) from app.ai.event_bus import AIEvent, get_event_bus from app.ai.rate_limiter import RateLimiter from app.ai.references import attach_references from app.ai.run_log_service import AIRunLogService from app.ai.schemas import CacheTypeEnum logger = logging.getLogger(__name__) # 单步调用默认超时(秒) # 2026-04-21:App2 prompt 中文 key 膨胀后 AI 响应延迟常突破 120s,上调至 180s _STEP_TIMEOUT = 180 # App2 预热时间维度(与 prompts/app2_finance_prompt.DIMENSION_MAP 对齐) APP2_TIME_DIMENSIONS = ( "this_month", "last_month", "this_week", "last_week", "this_quarter", "last_quarter", "last_3_months", "last_6_months", ) # App2 预热区域维度(与 prompts/app2_finance_prompt.AREA_OPTIONS 对齐) APP2_AREA_OPTIONS = ( "all", "hall", "hallA", "hallB", "hallC", "vip", "snooker", "mahjong", "ktv", ) # 2026-04-23 · 72 组合拆分为 2 个 APP: # - app2_finance 处理 area='all' 的 8 组合 # - app2a_finance_area 处理 area != 'all' 的 64 组合 APP2A_AREA_OPTIONS = tuple(a for a in APP2_AREA_OPTIONS if a != "all") def _app2_target_id(time_dimension: str, area: str) -> str: """App2 缓存 target_id:time_dimension__area(双下划线分隔,避免歧义)。 例:this_month__all / last_month__hallA。 前端 board-finance.ts _loadAIInsights 需用相同拼装规则读取缓存。 """ return f"{time_dimension}__{area}" def _publish_alert( site_id: int, app_name: str, alert_type: str, message: str, log_id: int | None = None, ) -> None: """Phase 3.3:推送 alert_created 事件给 WS 订阅者(/ws/ai-alerts/{site_id})。 alert_type 取值:circuit_open / rate_limited / budget_exceeded / timeout / failed 异常仅记日志,不影响主流程。 """ try: get_event_bus().publish(AIEvent( type="alert_created", site_id=site_id, payload={ "app_type": app_name, "alert_type": alert_type, "message": message, "log_id": log_id, }, )) except Exception: logger.debug("alert_created 事件广播失败", exc_info=True) def _update_trigger_job_status( job_id: int, status: str, error_message: str | None = None, set_started: bool = False, set_finished: bool = False, ) -> None: """更新 ai_trigger_jobs 表的状态(仅当 job_id 存在于 DB 时生效)。""" from app.database import get_connection parts = ["status = %s"] params: list[Any] = [status] if set_started: parts.append("started_at = NOW()") if set_finished: parts.append("finished_at = NOW()") if error_message is not None: parts.append("error_message = %s") params.append(error_message) params.append(job_id) conn = get_connection() try: with conn.cursor() as cur: cur.execute( f"UPDATE biz.ai_trigger_jobs SET {', '.join(parts)} WHERE id = %s", params, ) conn.commit() except Exception: conn.rollback() logger.debug("更新 trigger_job %d 状态失败(可能为内存 job)", job_id, exc_info=True) finally: conn.close() @dataclass class TriggerEvent: """统一事件触发载体。""" event_type: str # consumption / note_created / task_assigned / dws_completed site_id: int member_id: int | None = None connector_type: str = "feiqiu" payload: dict = field(default_factory=dict) is_forced: bool = False # 事件类型常量 EVENT_CONSUMPTION = "consumption" EVENT_NOTE_CREATED = "note_created" EVENT_TASK_ASSIGNED = "task_assigned" EVENT_DWS_COMPLETED = "dws_completed" # 事件类型 → 调用链描述(用于 ai_trigger_jobs.app_chain) _EVENT_CHAIN_MAP: dict[str, str] = { EVENT_CONSUMPTION: "app3→app8→app7", EVENT_NOTE_CREATED: "app6→app8", EVENT_TASK_ASSIGNED: "app4→app5", EVENT_DWS_COMPLETED: "app2", } class AIDispatcher: """AI 事件调度与调用链编排器。 集成熔断器、限流器、Token 预算控制, 每步调用前依次检查:熔断→限流→预算→调用→记录日志。 """ def __init__( self, client: DashScopeClient, cache_svc: AICacheService, conv_svc: ConversationService, circuit_breaker: CircuitBreaker, rate_limiter: RateLimiter, budget_tracker: BudgetTracker, run_log_svc: AIRunLogService, config: AIConfig, ) -> None: self.client = client self.cache_svc = cache_svc self.conv_svc = conv_svc self.circuit_breaker = circuit_breaker self.rate_limiter = rate_limiter self.budget_tracker = budget_tracker self.run_log_svc = run_log_svc self.config = config # 内存去重集合:(event_type, member_id, site_id, date_str) # DB 迁移完成后改为查询 ai_trigger_jobs 表 self._dedup_set: set[tuple[str, int | None, int, str]] = set() # 内存 trigger_job 计数器(DB 迁移完成后改为 INSERT RETURNING id) self._next_job_id = 1 # ── 统一事件入口 ───────────────────────────────────── async def handle_trigger(self, event: TriggerEvent) -> int: """统一事件入口。记录 trigger_job 后异步执行调用链。 返回 trigger_job_id(当前为内存自增,DB 迁移后改为数据库 ID)。 """ job_id = self._next_job_id self._next_job_id += 1 app_chain = _EVENT_CHAIN_MAP.get(event.event_type, "unknown") logger.info( "收到触发事件: job_id=%d event_type=%s site_id=%d chain=%s", job_id, event.event_type, event.site_id, app_chain, ) # 去重检查(is_forced 跳过) if not event.is_forced and await self._check_dedup(event): logger.info( "事件去重跳过: job_id=%d event_type=%s site_id=%d member_id=%s", job_id, event.event_type, event.site_id, event.member_id, ) return job_id # 记录去重键 dedup_key = ( event.event_type, event.member_id, event.site_id, date.today().isoformat(), ) self._dedup_set.add(dedup_key) # 后台异步执行调用链,不阻塞返回 asyncio.create_task(self._execute_chain(job_id, event)) return job_id # ── 调用链分发 ─────────────────────────────────────── async def _execute_chain(self, job_id: int, event: TriggerEvent) -> None: """执行调用链,根据 event_type 分发到对应处理器。""" handler_map: dict[str, Callable] = { EVENT_CONSUMPTION: self._handle_consumption, EVENT_NOTE_CREATED: self._handle_note, EVENT_TASK_ASSIGNED: self._handle_task_assigned, EVENT_DWS_COMPLETED: self._handle_dws_completed, } handler = handler_map.get(event.event_type) if handler is None: logger.error("未知事件类型: %s (job_id=%d)", event.event_type, job_id) _update_trigger_job_status(job_id, "failed", error_message=f"未知事件类型: {event.event_type}") return # 标记开始执行 _update_trigger_job_status(job_id, "running", set_started=True) # 外层超时按事件类型给预算: # - 消费/备注/任务分配:最多 4-5 个 App 串行,10 min 足够 # - DWS 完成:72 组合(8 时间 × 9 区域),每组合 ~30-120s,需 ~2.5h 预算 chain_timeout = ( _STEP_TIMEOUT * len(APP2_TIME_DIMENSIONS) * len(APP2_AREA_OPTIONS) + 600 if event.event_type == EVENT_DWS_COMPLETED else _STEP_TIMEOUT * 5 ) try: await asyncio.wait_for(handler(event), timeout=chain_timeout) logger.info("调用链完成: job_id=%d event_type=%s", job_id, event.event_type) _update_trigger_job_status(job_id, "completed", set_finished=True) except asyncio.TimeoutError: logger.error("调用链超时: job_id=%d event_type=%s", job_id, event.event_type) _update_trigger_job_status(job_id, "failed", error_message="调用链超时", set_finished=True) except Exception as exc: logger.exception("调用链异常: job_id=%d event_type=%s", job_id, event.event_type) _update_trigger_job_status( job_id, "failed", error_message=str(exc)[:500], set_finished=True, ) # ── 去重检查 ───────────────────────────────────────── async def _check_dedup(self, event: TriggerEvent) -> bool: """去重检查:(event_type, member_id, site_id, date) 是否已存在。 返回 True 表示重复(应跳过),False 表示不重复。 当前为内存实现,DB 迁移后改为查询 ai_trigger_jobs 表。 """ dedup_key = ( event.event_type, event.member_id, event.site_id, date.today().isoformat(), ) return dedup_key in self._dedup_set # ── 单步执行 ───────────────────────────────────────── async def _run_step( self, app_name: str, app_id: str, prompt: str, context: dict, ) -> dict | None: """执行单步:熔断检查→限流检查→预算检查→调用→记录日志。 失败返回 None,调用链继续执行后续步骤。 Args: app_name: 应用名称(如 "app3_clue"),用于日志 app_id: 百炼应用 ID prompt: 发送给应用的 prompt 字符串 context: 上下文信息(含 site_id、member_id 等) Returns: 解析后的 JSON dict,失败时返回 None """ site_id = context.get("site_id", 0) member_id = context.get("member_id") start_time = time.monotonic() # ── 1. 熔断检查 ── state = self.circuit_breaker.check(app_id) if state == CircuitState.OPEN: logger.warning("熔断器 OPEN,跳过: app=%s app_id=%s", app_name, app_id) try: log_id = self.run_log_svc.create_log( site_id=site_id, app_type=app_name, trigger_type="event", member_id=member_id, request_prompt=prompt, ) elapsed_ms = int((time.monotonic() - start_time) * 1000) self.run_log_svc.update_failed(log_id, "circuit_open", elapsed_ms) except Exception: logger.exception("记录 circuit_open 日志失败: app=%s", app_name) _publish_alert(site_id, app_name, "circuit_open", "熔断器已打开") return None # ── 2. 限流检查 ── if not self.rate_limiter.check_store_rate(site_id): logger.warning("限流超限,跳过: app=%s site_id=%d", app_name, site_id) try: log_id = self.run_log_svc.create_log( site_id=site_id, app_type=app_name, trigger_type="event", member_id=member_id, request_prompt=prompt, ) elapsed_ms = int((time.monotonic() - start_time) * 1000) self.run_log_svc.update_failed(log_id, "rate_limited", elapsed_ms) except Exception: logger.exception("记录 rate_limited 日志失败: app=%s", app_name) _publish_alert(site_id, app_name, "rate_limited", "门店限流超限") return None # ── 3. 预算检查 ── budget_status = self.budget_tracker.check_budget() if not budget_status.allowed: logger.warning( "预算超限,跳过: app=%s reason=%s", app_name, budget_status.reason, ) try: log_id = self.run_log_svc.create_log( site_id=site_id, app_type=app_name, trigger_type="event", member_id=member_id, request_prompt=prompt, ) elapsed_ms = int((time.monotonic() - start_time) * 1000) self.run_log_svc.update_failed( log_id, f"budget_exceeded:{budget_status.reason}", elapsed_ms, ) except Exception: logger.exception("记录 budget_exceeded 日志失败: app=%s", app_name) _publish_alert( site_id, app_name, "budget_exceeded", f"Token 预算超限: {budget_status.reason}", ) return None # ── 4. 创建日志记录(pending → running) ── log_id: int | None = None try: log_id = self.run_log_svc.create_log( site_id=site_id, app_type=app_name, trigger_type="event", member_id=member_id, request_prompt=prompt, ) self.run_log_svc.update_running(log_id) except Exception: logger.exception("创建/更新运行日志失败: app=%s", app_name) # ── 5. 调用 DashScope ── try: result, tokens_used, _session_id = await asyncio.wait_for( self.client.call_app(app_id, prompt), timeout=_STEP_TIMEOUT, ) # 成功:更新日志 + 熔断器 elapsed_ms = int((time.monotonic() - start_time) * 1000) if log_id is not None: try: self.run_log_svc.update_success( log_id, response_text=json.dumps(result, ensure_ascii=False), tokens_used=tokens_used, latency_ms=elapsed_ms, ) except Exception: logger.exception("更新 success 日志失败: app=%s log_id=%d", app_name, log_id) self.circuit_breaker.record_success(app_id) logger.info("调用成功: app=%s tokens=%d latency=%dms", app_name, tokens_used, elapsed_ms) # Phase 1.3:按 app_name + context 注入 _references 元数据(非破坏性) result = attach_references(app_name, result, context) or result return result except asyncio.TimeoutError: # 超时 elapsed_ms = int((time.monotonic() - start_time) * 1000) logger.error("调用超时: app=%s latency=%dms", app_name, elapsed_ms) if log_id is not None: try: self.run_log_svc.update_timeout(log_id, elapsed_ms) except Exception: logger.exception("更新 timeout 日志失败: app=%s", app_name) _publish_alert(site_id, app_name, "timeout", f"调用超时 ({elapsed_ms}ms)", log_id) return None except Exception as exc: # 其他失败 elapsed_ms = int((time.monotonic() - start_time) * 1000) error_msg = f"{type(exc).__name__}: {exc}" logger.exception("调用失败: app=%s error=%s", app_name, error_msg) if log_id is not None: try: self.run_log_svc.update_failed(log_id, error_msg, elapsed_ms) except Exception: logger.exception("更新 failed 日志失败: app=%s", app_name) self.circuit_breaker.record_failure(app_id) _publish_alert(site_id, app_name, "failed", error_msg[:200], log_id) return None # ── 缓存写入辅助 ───────────────────────────────────── def _write_cache( self, cache_type: str, site_id: int, target_id: str, result: dict | None, triggered_by: str, score: int | None = None, ) -> None: """统一缓存写入入口,结果为 None 时跳过。 Phase 1.3:result['_references'] 已由 _run_step 成功返回前注入。 """ if result is None: return try: self.cache_svc.write_cache( cache_type=cache_type, site_id=site_id, target_id=target_id, result_json=result, triggered_by=triggered_by, score=score, ) except Exception: logger.exception( "缓存写入失败: cache_type=%s site_id=%s target_id=%s", cache_type, site_id, target_id, ) return # Phase 1.4:广播 cache_updated 事件,admin-web / 小程序 WS 订阅可实时刷新 try: from app.ai.event_bus import AIEvent, get_event_bus get_event_bus().publish(AIEvent( type="cache_updated", site_id=site_id, payload={ "cache_type": cache_type, "target_id": target_id, "triggered_by": triggered_by, }, )) except Exception: logger.debug("cache_updated 事件广播失败(不影响主流程)", exc_info=True) # ── App8 幂等写入 member_retention_clue ───────────── def _write_retention_clue( self, member_id: int, site_id: int, consolidate_result: dict, source: str, ) -> None: """全量替换 member_retention_clue:DELETE 同源旧记录 + INSERT 新记录(事务)。 同一 member 同一 site 同一 source 当天只保留最新一批。 人工线索(source='manual')不受影响。 事务失败自动回滚。 字段映射: - category → category - emoji + " " + summary → summary(如 "📅 偏好周末下午时段消费") - detail → detail - providers → recorded_by_name """ from app.database import get_connection clues = consolidate_result.get("clues", []) if not clues: logger.info( "App8 无线索数据,跳过写入: member_id=%d site_id=%d", member_id, site_id, ) return conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ DELETE FROM member_retention_clue WHERE member_id = %s AND site_id = %s AND source = %s """, (member_id, site_id, source), ) deleted = cur.rowcount for clue in clues: emoji = clue.get("emoji", "") raw_summary = clue.get("summary", "") summary = f"{emoji} {raw_summary}" if emoji else raw_summary cur.execute( """ INSERT INTO member_retention_clue (member_id, category, summary, detail, site_id, source, recorded_by_name, recorded_by_assistant_id) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL) """, ( member_id, clue.get("category", "客户基础"), summary, clue.get("detail", ""), site_id, source, clue.get("providers", ""), ), ) conn.commit() logger.info( "维客线索全量替换完成: member_id=%d site_id=%d source=%s " "deleted=%d inserted=%d", member_id, site_id, source, deleted, len(clues), ) except Exception: conn.rollback() raise finally: conn.close() # ── App8 输入辅助:提取最近 App3/App6 线索 ───────── def _fetch_recent_clues( self, cache_type: str, site_id: int, member_id: int, ) -> tuple[list[dict], str | None]: """从 ai_cache 提取最近一次结果的 clues 数组与生成时间。""" try: latest = self.cache_svc.get_latest( cache_type, site_id, str(member_id), ) except Exception: logger.exception( "查询缓存失败: cache_type=%s site_id=%s member_id=%s", cache_type, site_id, member_id, ) return [], None if not latest: return [], None result_json = latest.get("result_json") or {} if isinstance(result_json, str): try: result_json = json.loads(result_json) except Exception: result_json = {} clues = result_json.get("clues", []) if isinstance(result_json, dict) else [] return clues, latest.get("created_at") # ── 事件处理器 ─────────────────────────────────────── async def _handle_consumption(self, event: TriggerEvent) -> None: """消费事件链:App3 → App8 → App7(无助教)/ + App4 → App5(有助教)。""" site_id = event.site_id member_id = event.member_id if member_id is None: logger.error("消费事件缺少 member_id: site_id=%d", site_id) return has_assistant = event.payload.get("has_assistant", False) assistant_id = event.payload.get("assistant_id") if has_assistant else None context = {"site_id": site_id, "member_id": member_id} # ── Step 1: App3 客户线索分析 ── try: app3_prompt = await build_app3_prompt( {"site_id": site_id, "member_id": member_id}, self.cache_svc, ) except Exception: logger.exception("App3 prompt 拼装失败: member_id=%s", member_id) app3_prompt = "" app3_result = None if app3_prompt: app3_result = await self._run_step( "app3_clue", self.config.app_id_3_clue, app3_prompt, context, ) self._write_cache( CacheTypeEnum.APP3_CLUE.value, site_id, str(member_id), app3_result, "consumption", ) # ── Step 2: App8 线索整合 ── app3_clues = (app3_result or {}).get("clues", []) if isinstance(app3_result, dict) else [] app6_clues, app6_generated_at = self._fetch_recent_clues( CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, member_id, ) try: app8_prompt = await build_app8_prompt({ "site_id": site_id, "member_id": member_id, "app3_clues": app3_clues, "app6_clues": app6_clues, "app3_generated_at": None, # App3 刚生成,无 created_at "app6_generated_at": app6_generated_at, }) except Exception: logger.exception("App8 prompt 拼装失败: member_id=%s", member_id) app8_prompt = "" app8_result = None if app8_prompt: app8_result = await self._run_step( "app8_consolidate", self.config.app_id_8_consolidate, app8_prompt, context, ) self._write_cache( CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, str(member_id), app8_result, "consumption", ) if app8_result is not None: try: self._write_retention_clue( member_id=member_id, site_id=site_id, consolidate_result=app8_result, source="ai_consumption", ) except Exception: logger.exception( "App8 维客线索写入失败: site_id=%d member_id=%d", site_id, member_id, ) # ── Step 3: App7 客户分析 ── try: app7_prompt = await build_app7_prompt( {"site_id": site_id, "member_id": member_id}, self.cache_svc, ) except Exception: logger.exception("App7 prompt 拼装失败: member_id=%s", member_id) app7_prompt = "" if app7_prompt: app7_result = await self._run_step( "app7_customer", self.config.app_id_7_customer, app7_prompt, context, ) self._write_cache( CacheTypeEnum.APP7_CUSTOMER_ANALYSIS.value, site_id, str(member_id), app7_result, "consumption", ) # ── 有助教时:App4 → App5 ── if has_assistant and assistant_id is not None: await self._run_app4_app5( site_id=site_id, assistant_id=int(assistant_id), member_id=member_id, triggered_by="consumption", ) async def _handle_note(self, event: TriggerEvent) -> None: """备注事件链:App6 → App8。""" site_id = event.site_id member_id = event.member_id if member_id is None: logger.error("备注事件缺少 member_id: site_id=%d", site_id) return context = {"site_id": site_id, "member_id": member_id} # ── Step 1: App6 备注分析 ── try: app6_prompt = await build_app6_prompt({ "site_id": site_id, "member_id": member_id, "note_content": event.payload.get("note_content", ""), "noted_by_name": event.payload.get("noted_by_name", ""), "noted_by_created_at": event.payload.get("noted_by_created_at", ""), }, self.cache_svc) except Exception: logger.exception("App6 prompt 拼装失败: member_id=%s", member_id) app6_prompt = "" app6_result = None if app6_prompt: app6_result = await self._run_step( "app6_note", self.config.app_id_6_note, app6_prompt, context, ) score = (app6_result or {}).get("score") if isinstance(app6_result, dict) else None self._write_cache( CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, str(member_id), app6_result, "note_created", score=score, ) # ── Step 2: App8 线索整合 ── app6_clues = (app6_result or {}).get("clues", []) if isinstance(app6_result, dict) else [] app3_clues, app3_generated_at = self._fetch_recent_clues( CacheTypeEnum.APP3_CLUE.value, site_id, member_id, ) try: app8_prompt = await build_app8_prompt({ "site_id": site_id, "member_id": member_id, "app3_clues": app3_clues, "app6_clues": app6_clues, "app3_generated_at": app3_generated_at, "app6_generated_at": None, # App6 刚生成 }) except Exception: logger.exception("App8 prompt 拼装失败: member_id=%s", member_id) return app8_result = await self._run_step( "app8_consolidate", self.config.app_id_8_consolidate, app8_prompt, context, ) self._write_cache( CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value, site_id, str(member_id), app8_result, "note_created", ) if app8_result is not None: try: self._write_retention_clue( member_id=member_id, site_id=site_id, consolidate_result=app8_result, source="ai_note", ) except Exception: logger.exception( "App8 维客线索写入失败: site_id=%d member_id=%d", site_id, member_id, ) async def _handle_task_assigned(self, event: TriggerEvent) -> None: """任务分配事件链:App4 → App5。""" site_id = event.site_id member_id = event.member_id assistant_id = event.payload.get("assistant_id") if member_id is None or assistant_id is None: logger.error( "任务分配事件缺少 member_id 或 assistant_id: site_id=%d", site_id, ) return await self._run_app4_app5( site_id=site_id, assistant_id=int(assistant_id), member_id=member_id, triggered_by="task_assigned", ) async def _run_app4_app5( self, *, site_id: int, assistant_id: int, member_id: int, triggered_by: str, ) -> None: """App4 → App5 串行执行(消费链含助教 + 任务分配链复用)。""" context = { "site_id": site_id, "member_id": member_id, "assistant_id": assistant_id, } target_id = f"{assistant_id}_{member_id}" # ── Step A: App4 关系分析 ── try: app4_prompt = await build_app4_prompt({ "site_id": site_id, "assistant_id": assistant_id, "member_id": member_id, }, self.cache_svc) except Exception: logger.exception( "App4 prompt 拼装失败: assistant=%s member=%s", assistant_id, member_id, ) app4_prompt = "" app4_result = None if app4_prompt: app4_result = await self._run_step( "app4_analysis", self.config.app_id_4_analysis, app4_prompt, context, ) self._write_cache( CacheTypeEnum.APP4_ANALYSIS.value, site_id, target_id, app4_result, triggered_by, ) # ── Step B: App5 话术参考(使用 App4 结果) ── try: app5_prompt = await build_app5_prompt({ "site_id": site_id, "assistant_id": assistant_id, "member_id": member_id, "app4_result": app4_result, }, self.cache_svc) except Exception: logger.exception( "App5 prompt 拼装失败: assistant=%s member=%s", assistant_id, member_id, ) return app5_result = await self._run_step( "app5_tactics", self.config.app_id_5_tactics, app5_prompt, context, ) self._write_cache( CacheTypeEnum.APP5_TACTICS.value, site_id, target_id, app5_result, triggered_by, ) async def _handle_dws_completed(self, event: TriggerEvent) -> None: """DWS 完成事件:App2 + App2a 联合预生成(8 时间维度 × 9 区域 = 72 组合)。 2026-04-23 · 72 组合拆分为两个百炼 APP: - app2_finance(全域版)处理 area='all' 的 8 组合 - app2a_finance_area(区域派生版)处理 area != 'all' 的 64 组合 每个组合独立调用百炼,结果写入 ai_cache,target_id=time_dimension__area。 单步失败不影响后续组合。熔断/限流/预算检查在 _run_step 内部生效, 达到阈值后会自动跳过剩余调用。 """ site_id = event.site_id context = {"site_id": site_id, "member_id": None} total = len(APP2_TIME_DIMENSIONS) * len(APP2_AREA_OPTIONS) ok = 0 for dimension in APP2_TIME_DIMENSIONS: # 1. 全域组合 · app2_finance try: prompt = await build_app2_prompt({ "site_id": site_id, "time_dimension": dimension, "area": "all", }) except Exception: logger.exception( "App2 prompt 拼装失败: site_id=%d dimension=%s area=all", site_id, dimension, ) else: result = await self._run_step( "app2_finance", self.config.app_id_2_finance, prompt, context, ) self._write_cache( CacheTypeEnum.APP2_FINANCE.value, site_id, _app2_target_id(dimension, "all"), result, "dws_completed", ) if result is not None: ok += 1 # 2. 区域组合 · app2a_finance_area(64 组合 = 8 时间 × 8 区域) for area in APP2A_AREA_OPTIONS: try: prompt = await build_app2a_area_prompt({ "site_id": site_id, "time_dimension": dimension, "area": area, }) except Exception: logger.exception( "app2a prompt 拼装失败: site_id=%d dimension=%s area=%s", site_id, dimension, area, ) continue result = await self._run_step( "app2a_finance_area", self.config.app_id_2a_finance_area, prompt, context, ) self._write_cache( CacheTypeEnum.APP2A_FINANCE_AREA.value, site_id, _app2_target_id(dimension, area), result, "dws_completed", ) if result is not None: ok += 1 logger.info( "App2 + App2a 预热完成: site_id=%d 成功=%d/%d", site_id, ok, total, ) # ── 按需单 App 执行(admin-web 重新生成按钮用) ────────────── async def run_single_app( self, app_type: str, context: dict, triggered_by: str = "admin_manual", ) -> dict | None: """按需执行单个 App,跳过链路编排。 用于 admin-web "重新生成" 按钮等场景:只跑一个 App 不触发下游链。 结果自动写入 ai_cache。 Args: app_type: 应用名称,支持 app2_finance ~ app8_consolidation context: 上下文参数。各 App 所需字段: - app2_finance: site_id + time_dimension - app3_clue: site_id + member_id - app4_analysis: site_id + member_id + assistant_id - app5_tactics: site_id + member_id + assistant_id + app4_result? - app6_note: site_id + member_id + note_content + noted_by_name + noted_by_created_at? - app7_customer: site_id + member_id - app8_consolidation: site_id + member_id(从 cache 自动拼 app3/app6 clues) triggered_by: 触发来源(日志用),默认 admin_manual Returns: 百炼返回的结构化 JSON;失败返回 None """ site_id = context.get("site_id") if site_id is None: raise ValueError("context 缺少 site_id") # 分派:app_type → (prompt builder, app_id, cache_type, target_id) if app_type == "app2_finance": dimension = context.get("time_dimension") if not dimension: raise ValueError("app2_finance 需要 time_dimension") area = context.get("area", "all") context = {**context, "area": area} prompt = await build_app2_prompt(context) app_id = self.config.app_id_2_finance cache_type = CacheTypeEnum.APP2_FINANCE.value target_id = _app2_target_id(str(dimension), str(area)) elif app_type == "app2a_finance_area": dimension = context.get("time_dimension") area = context.get("area") if not dimension: raise ValueError("app2a_finance_area 需要 time_dimension") if not area or area == "all": raise ValueError("app2a_finance_area 需要 area != 'all'(area='all' 请走 app2_finance)") prompt = await build_app2a_area_prompt(context) app_id = self.config.app_id_2a_finance_area cache_type = CacheTypeEnum.APP2A_FINANCE_AREA.value target_id = _app2_target_id(str(dimension), str(area)) elif app_type == "app3_clue": if context.get("member_id") is None: raise ValueError("app3_clue 需要 member_id") prompt = await build_app3_prompt(context, self.cache_svc) app_id = self.config.app_id_3_clue cache_type = CacheTypeEnum.APP3_CLUE.value target_id = str(context["member_id"]) elif app_type == "app4_analysis": if context.get("member_id") is None or context.get("assistant_id") is None: raise ValueError("app4_analysis 需要 member_id + assistant_id") prompt = await build_app4_prompt(context, self.cache_svc) app_id = self.config.app_id_4_analysis cache_type = CacheTypeEnum.APP4_ANALYSIS.value target_id = f"{context['assistant_id']}_{context['member_id']}" elif app_type == "app5_tactics": if context.get("member_id") is None or context.get("assistant_id") is None: raise ValueError("app5_tactics 需要 member_id + assistant_id") prompt = await build_app5_prompt(context, self.cache_svc) app_id = self.config.app_id_5_tactics cache_type = CacheTypeEnum.APP5_TACTICS.value target_id = f"{context['assistant_id']}_{context['member_id']}" elif app_type == "app6_note": if context.get("member_id") is None: raise ValueError("app6_note 需要 member_id") prompt = await build_app6_prompt(context, self.cache_svc) app_id = self.config.app_id_6_note cache_type = CacheTypeEnum.APP6_NOTE_ANALYSIS.value target_id = str(context["member_id"]) elif app_type == "app7_customer": if context.get("member_id") is None: raise ValueError("app7_customer 需要 member_id") prompt = await build_app7_prompt(context, self.cache_svc) app_id = self.config.app_id_7_customer cache_type = CacheTypeEnum.APP7_CUSTOMER_ANALYSIS.value target_id = str(context["member_id"]) elif app_type == "app8_consolidation": if context.get("member_id") is None: raise ValueError("app8_consolidation 需要 member_id") # 自动从 cache 拼 app3/app6 clues member_id = context["member_id"] app3_clues, app3_at = self._fetch_recent_clues( CacheTypeEnum.APP3_CLUE.value, site_id, member_id, ) app6_clues, app6_at = self._fetch_recent_clues( CacheTypeEnum.APP6_NOTE_ANALYSIS.value, site_id, member_id, ) prompt = await build_app8_prompt({ "site_id": site_id, "member_id": member_id, "app3_clues": app3_clues, "app6_clues": app6_clues, "app3_generated_at": app3_at, "app6_generated_at": app6_at, }) app_id = self.config.app_id_8_consolidate cache_type = CacheTypeEnum.APP8_CLUE_CONSOLIDATED.value target_id = str(member_id) else: raise ValueError(f"不支持的 app_type: {app_type}") # 执行 + 写缓存(复用 _run_step 熔断/限流/预算/日志逻辑) result = await self._run_step(app_type, app_id, prompt, context) score = None if app_type == "app6_note" and isinstance(result, dict): score = result.get("score") self._write_cache(cache_type, site_id, target_id, result, triggered_by, score=score) # App8 额外写入 member_retention_clue(幂等替换) if app_type == "app8_consolidation" and result is not None: try: self._write_retention_clue( member_id=context["member_id"], site_id=site_id, consolidate_result=result, source="ai_consumption" if context.get("source") != "ai_note" else "ai_note", ) except Exception: logger.exception( "App8 manual: 维客线索写入失败 site_id=%d member_id=%s", site_id, context.get("member_id"), ) return result # ── 事件处理器注册(向后兼容) ──────────────────────────── def _create_ai_event_handlers(dispatcher: AIDispatcher) -> dict[str, Callable]: """创建 AI 事件处理器,用于注册到 trigger_scheduler。 每个处理器从 payload 提取参数,构造 TriggerEvent, 通过 asyncio.create_task 后台执行,不阻塞调用方。 Returns: {event_job_type: handler_func} 映射 """ async def handle_consumption_settled(payload: dict | None = None, **_kw: Any) -> None: """消费结算事件处理器(async 入口)。""" if not payload: logger.warning("consumption_settled 事件缺少 payload") return event = TriggerEvent( event_type=EVENT_CONSUMPTION, site_id=payload["site_id"], member_id=payload.get("member_id"), payload=payload, ) await dispatcher.handle_trigger(event) async def handle_note_created(payload: dict | None = None, **_kw: Any) -> None: """备注创建事件处理器。""" if not payload: logger.warning("note_created 事件缺少 payload") return event = TriggerEvent( event_type=EVENT_NOTE_CREATED, site_id=payload["site_id"], member_id=payload.get("member_id"), payload=payload, ) await dispatcher.handle_trigger(event) async def handle_task_assigned(payload: dict | None = None, **_kw: Any) -> None: """任务分配事件处理器。""" if not payload: logger.warning("task_assigned 事件缺少 payload") return event = TriggerEvent( event_type=EVENT_TASK_ASSIGNED, site_id=payload["site_id"], member_id=payload.get("member_id"), payload=payload, ) await dispatcher.handle_trigger(event) async def handle_dws_completed(payload: dict | None = None, **_kw: Any) -> None: """DWS 完成事件处理器。""" if not payload: logger.warning("dws_completed 事件缺少 payload") return event = TriggerEvent( event_type=EVENT_DWS_COMPLETED, site_id=payload["site_id"], member_id=payload.get("member_id"), payload=payload, ) await dispatcher.handle_trigger(event) async def handle_app2_prewarm(**_kw: Any) -> None: """App2 财务洞察 cron 预热处理器(每日 10:00 触发)。 对所有 active 门店发起 ai_dws_completed 事件,触发 App2 预生成: 8 时间维度 × 9 区域 = 72 组合。 cron 签名 (conn, job_id),这里不需要,用 **_kw 忽略。 """ from app.database import get_connection from app.services.trigger_scheduler import fire_event conn = get_connection() try: with conn.cursor() as cur: cur.execute( "SELECT DISTINCT site_id FROM biz.sites WHERE site_id IS NOT NULL" ) site_ids = [r[0] for r in cur.fetchall()] conn.commit() finally: conn.close() for sid in site_ids: try: fire_event("ai_dws_completed", {"site_id": sid}) except Exception: logger.exception("cron 触发 ai_dws_completed 失败: site_id=%s", sid) logger.info("App2 cron 预热完成: 已触发 %d 个门店", len(site_ids)) return { "ai_consumption_settled": handle_consumption_settled, "ai_note_created": handle_note_created, "ai_task_assigned": handle_task_assigned, "ai_dws_completed": handle_dws_completed, "ai_dws_prewarm": handle_app2_prewarm, } # ── 模块级 AIDispatcher 单例 ───────────────────────────── _dispatcher_instance: AIDispatcher | None = None def get_dispatcher() -> AIDispatcher: """获取全局 AIDispatcher 实例。 须在 main.py lifespan 调用 register_ai_handlers 后使用。 """ if _dispatcher_instance is None: raise RuntimeError( "AIDispatcher 未初始化。确认 main.py lifespan 已调用 register_ai_handlers。" ) return _dispatcher_instance def register_ai_handlers(dispatcher: AIDispatcher) -> None: """将 AI 事件处理器注册到 trigger_scheduler,并设置模块级实例。 在 FastAPI lifespan 中调用,将 AI 事件处理器 注册为 trigger_scheduler 的 job handler。 """ global _dispatcher_instance from app.services.trigger_scheduler import register_job _dispatcher_instance = dispatcher handlers = _create_ai_event_handlers(dispatcher) for job_type, handler in handlers.items(): register_job(job_type, handler) logger.info("已注册 AI 事件处理器: %s", job_type)