Files
Neo-ZQYY/apps/backend/app/ai/dispatcher.py
Neo 8638ecad2a feat(backend): 新增 app2a 区域财务洞察 APP 派生 · dispatcher 72 循环拆分
1. apps/backend/app/ai/prompts/app2a_finance_area_prompt.py (新建):
   - payload: 业态说明 + 区域占比 + 对比口径 + 核心 KPI + 优惠构成
     + 助教成本 + 区域级单位经济 + 按星期聚合 + 日粒度异常 + 行业基线
   - 5 个区域级辅助函数:_fetch_area_daily_series / _build_area_unit_economics
     / _aggregate_by_weekday_area / _detect_anomaly_days_area / _fetch_area_share
   - AREA_INDUSTRY_TRAITS 字典(7 业态 trait + peer 描述)
   - 复用 app2_finance_prompt 的 _build_coach_kpi / _build_discount_kpi 等公共函数

2. config.py: AIConfig 增加 app_id_2a_finance_area + DASHSCOPE_APP_ID_2A_FINANCE_AREA

3. schemas.py: CacheTypeEnum 增加 APP2A_FINANCE_AREA

4. dispatcher.py:
   - APP2A_AREA_OPTIONS 常量(8 业态 · area != 'all')
   - _handle_dws_completed 72 循环拆分:
     area='all' 走 app2_finance · 其他 8 业态走 app2a_finance_area
   - run_single_app 新增 elif 'app2a_finance_area' 分支(拒绝 area='all')

5. admin_ai.py: _SUPPORTED_APP_TYPES 加 'app2a_finance_area'

6. prompts/__init__.py: 导出 build_app2a_area_prompt

7. .env: 追加 DASHSCOPE_APP_ID_2A_FINANCE_AREA 百炼 APP ID

实测:7 项集成单测全通过(config/cache_type/router/prompts/dispatcher 常量/
4 业态 prompt 构建/拒绝 area=all)· 端到端实调 vip 组合返回 12 条高质量洞察
严格遵守 v1.2 system prompt 全部 7 项硬约束(H1-H7)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:55:26 +08:00

1242 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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-21App2 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_idtime_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.3result['_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_clueDELETE 同源旧记录 + 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_cachetarget_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_area64 组合 = 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)