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>
1242 lines
47 KiB
Python
1242 lines
47 KiB
Python
"""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)
|