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>
This commit is contained in:
2
.env
2
.env
@@ -127,6 +127,8 @@ DASHSCOPE_APP_ID_5_TACTICS=46f54e6053df4bb0b83be29366025cf6
|
|||||||
DASHSCOPE_APP_ID_6_NOTE=025bb344146b4e4e8be30c444adab3b4
|
DASHSCOPE_APP_ID_6_NOTE=025bb344146b4e4e8be30c444adab3b4
|
||||||
DASHSCOPE_APP_ID_7_CUSTOMER=df35e06991b24d49971c03c6428a9c87
|
DASHSCOPE_APP_ID_7_CUSTOMER=df35e06991b24d49971c03c6428a9c87
|
||||||
DASHSCOPE_APP_ID_8_CONSOLIDATE=407dfb89283b4196934eec5fefe3ebc2
|
DASHSCOPE_APP_ID_8_CONSOLIDATE=407dfb89283b4196934eec5fefe3ebc2
|
||||||
|
# 应用 2a:区域财务洞察(64 组合 · area != 'all' · 板块 C/E 重分工 · 新增 H7 业态特征硬约束)
|
||||||
|
DASHSCOPE_APP_ID_2A_FINANCE_AREA=0ae965029bc54706bcff44f511ac716b
|
||||||
# 应用 9:Session 日志摘要生成(Kiro agent_on_stop + batch_generate_summaries 使用)
|
# 应用 9:Session 日志摘要生成(Kiro agent_on_stop + batch_generate_summaries 使用)
|
||||||
DASHSCOPE_APP_ID_SUMMARY=e0cf8913b1ee4a4eb9464cc1ee0bf300
|
DASHSCOPE_APP_ID_SUMMARY=e0cf8913b1ee4a4eb9464cc1ee0bf300
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class AIConfig:
|
|||||||
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID(可选)
|
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID(可选)
|
||||||
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
|
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
|
||||||
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
|
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
|
||||||
|
app_id_2a_finance_area: str # DASHSCOPE_APP_ID_2A_FINANCE_AREA(2026-04-23 新增,区域财务洞察)
|
||||||
app_id_3_clue: str # DASHSCOPE_APP_ID_3_CLUE
|
app_id_3_clue: str # DASHSCOPE_APP_ID_3_CLUE
|
||||||
app_id_4_analysis: str # DASHSCOPE_APP_ID_4_ANALYSIS
|
app_id_4_analysis: str # DASHSCOPE_APP_ID_4_ANALYSIS
|
||||||
app_id_5_tactics: str # DASHSCOPE_APP_ID_5_TACTICS
|
app_id_5_tactics: str # DASHSCOPE_APP_ID_5_TACTICS
|
||||||
@@ -37,6 +38,7 @@ class AIConfig:
|
|||||||
"DASHSCOPE_API_KEY": "api_key",
|
"DASHSCOPE_API_KEY": "api_key",
|
||||||
"DASHSCOPE_APP_ID_1_CHAT": "app_id_1_chat",
|
"DASHSCOPE_APP_ID_1_CHAT": "app_id_1_chat",
|
||||||
"DASHSCOPE_APP_ID_2_FINANCE": "app_id_2_finance",
|
"DASHSCOPE_APP_ID_2_FINANCE": "app_id_2_finance",
|
||||||
|
"DASHSCOPE_APP_ID_2A_FINANCE_AREA": "app_id_2a_finance_area",
|
||||||
"DASHSCOPE_APP_ID_3_CLUE": "app_id_3_clue",
|
"DASHSCOPE_APP_ID_3_CLUE": "app_id_3_clue",
|
||||||
"DASHSCOPE_APP_ID_4_ANALYSIS": "app_id_4_analysis",
|
"DASHSCOPE_APP_ID_4_ANALYSIS": "app_id_4_analysis",
|
||||||
"DASHSCOPE_APP_ID_5_TACTICS": "app_id_5_tactics",
|
"DASHSCOPE_APP_ID_5_TACTICS": "app_id_5_tactics",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,28 @@
|
|||||||
# AI Prompt 模板子模块
|
"""AI 应用 Prompt 拼装模块。
|
||||||
|
|
||||||
|
8 个百炼自定义应用的后端 prompt 拼装函数集中此处。
|
||||||
|
- 所有函数返回 str:直接传给 dashscope.Application.call(prompt=...)
|
||||||
|
- system prompt 在百炼控制台配置,本模块只负责拼数据上下文 JSON
|
||||||
|
- 数据源走 data_fetchers / board_service,集中真实业务数据
|
||||||
|
- 失败降级:数据获取失败时拼"_data_warnings"字段,不阻断 AI 调用
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.ai.prompts.app2_finance_prompt import build_prompt as build_app2_prompt
|
||||||
|
from app.ai.prompts.app2a_finance_area_prompt import build_prompt as build_app2a_area_prompt
|
||||||
|
from app.ai.prompts.app3_clue_prompt import build_prompt as build_app3_prompt
|
||||||
|
from app.ai.prompts.app4_analysis_prompt import build_prompt as build_app4_prompt
|
||||||
|
from app.ai.prompts.app5_tactics_prompt import build_prompt as build_app5_prompt
|
||||||
|
from app.ai.prompts.app6_note_prompt import build_prompt as build_app6_prompt
|
||||||
|
from app.ai.prompts.app7_customer_prompt import build_prompt as build_app7_prompt
|
||||||
|
from app.ai.prompts.app8_consolidation_prompt import build_prompt as build_app8_prompt
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"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",
|
||||||
|
]
|
||||||
|
|||||||
498
apps/backend/app/ai/prompts/app2a_finance_area_prompt.py
Normal file
498
apps/backend/app/ai/prompts/app2a_finance_area_prompt.py
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
"""应用 2a 区域财务洞察 Prompt 拼装(app2_finance 的区域派生版本)。
|
||||||
|
|
||||||
|
面向 72 组合中 area != 'all' 的 64 个组合(8 时间 × 8 业态)。
|
||||||
|
|
||||||
|
差异点(相较 app2_finance):
|
||||||
|
- payload 新增顶层字段:「业态说明」「区域占比」
|
||||||
|
- 派生比率精简:仅「人力成本占成交收入比」「优惠侵蚀率」(其他比率区域级无法计算)
|
||||||
|
- 单位经济区域级:支持客单价/日均订单数及环比(暂不输出会员占比,与 v1.2 system prompt H6 对齐)
|
||||||
|
- 按星期聚合区域级:无「日均现金流入」(区域级无 cash_inflow 数据)
|
||||||
|
- 日粒度异常区域级:仅对 gross_amount 做异常检测(无 cash_inflow)
|
||||||
|
- 不注入:预收资产/现金流入/现金流出/储值卡余额变化(全店级字段,区域级无业务意义)
|
||||||
|
|
||||||
|
数据源:
|
||||||
|
- 主数据:board_service.get_finance_board(time, area, compare=1)
|
||||||
|
- 日粒度:etl 库 app.v_dws_finance_area_daily(按 area_code 过滤)
|
||||||
|
- 区域占比:调用 board_service 两次(一次区域 + 一次 all)后派生
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.board_service import _calc_date_range, _calc_prev_range, get_finance_board
|
||||||
|
|
||||||
|
# 复用 app2_finance_prompt 的公共常量与辅助函数
|
||||||
|
from app.ai.prompts.app2_finance_prompt import (
|
||||||
|
AREA_LABELS,
|
||||||
|
DIMENSION_LABELS,
|
||||||
|
DIMENSION_MAP,
|
||||||
|
INDUSTRY_BASELINES,
|
||||||
|
_aggregate_expense,
|
||||||
|
_build_coach_kpi,
|
||||||
|
_build_discount_kpi,
|
||||||
|
_slim,
|
||||||
|
_translate_keys,
|
||||||
|
_WEEKDAY_MIN_DAYS,
|
||||||
|
_ANOMALY_DEVIATION,
|
||||||
|
_ANOMALY_MAX_ITEMS,
|
||||||
|
_ANOMALY_MIN_DAYS,
|
||||||
|
_ANOMALY_MIN_SAME_WEEKDAY,
|
||||||
|
_WEEKDAY_ZH,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# 业态特征字典(与 v1.2 system prompt「三、业态特征」章节对齐)
|
||||||
|
# trait:业态的数据表征(客单/订单密度/会员占比/周期规律)
|
||||||
|
# peer:典型对比项(给 AI 做区域对比时的参照方向)
|
||||||
|
AREA_INDUSTRY_TRAITS: dict[str, dict[str, str]] = {
|
||||||
|
"hall": {
|
||||||
|
"trait": "大厅(合并 hallA+B+C)· 散客主力 · 客单价中等 · 订单密度最高 · 会员占比相对低",
|
||||||
|
"peer": "与 VIP 包厢对比单客贡献差异 · 与团购占比对比获客成本",
|
||||||
|
},
|
||||||
|
"hallA": {
|
||||||
|
"trait": "A 区大厅 · 散客主力 · 客单价中等 · 订单密度高",
|
||||||
|
"peer": "与 hallB/hallC 对比识别区位差异 · 与 hall 合计对比看单区占比",
|
||||||
|
},
|
||||||
|
"hallB": {
|
||||||
|
"trait": "B 区大厅 · 散客主力 · 客单价中等 · 订单密度高",
|
||||||
|
"peer": "与 hallA/hallC 对比识别区位差异",
|
||||||
|
},
|
||||||
|
"hallC": {
|
||||||
|
"trait": "C 区大厅(含 TV 台/美洲豹赛台)· 散客主力 · 客单价中等偏上 · 订单密度较高",
|
||||||
|
"peer": "与 hallA/hallB 对比识别区位差异",
|
||||||
|
},
|
||||||
|
"vip": {
|
||||||
|
"trait": "VIP 台球包厢 · 会员主力 · 客单价显著高于大厅 2-3 倍 · 订单密度低 · 助教服务收入占比高",
|
||||||
|
"peer": "与 hall 大厅对比单客贡献 · 与 snooker 对比高客单群体差异",
|
||||||
|
},
|
||||||
|
"snooker": {
|
||||||
|
"trait": "斯诺克 · 专业台球爱好者 · 客单价中高 · 会员占比较高 · 周末/夜场爆满",
|
||||||
|
"peer": "与 VIP 对比高端群体结构 · 与 hall 对比专业 vs 大众",
|
||||||
|
},
|
||||||
|
"mahjong": {
|
||||||
|
"trait": "麻将房 · 散客 + 小团 · 客单价高(时长计费)· 停留久 · 订单密度低 · 助教参与度极低",
|
||||||
|
"peer": "与 KTV 对比包间型业态 · 与 hall 对比客单价与时长",
|
||||||
|
},
|
||||||
|
"ktv": {
|
||||||
|
"trait": "团建房 · 团建场景 · 客单价集中在套餐 · 订单密度低 · 周末峰值明显 · 助教几乎不参与",
|
||||||
|
"peer": "与 mahjong 对比包间型业态 · 与 vip 对比高客单群体",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_area_daily_series(
|
||||||
|
site_id: int, start_date: str, end_date: str, area_code: str,
|
||||||
|
) -> list[tuple] | None:
|
||||||
|
"""查区域级日粒度 [start, end],供单位经济/按星期/异常检测复用。
|
||||||
|
|
||||||
|
返回字段顺序:(stat_date, gross, order_count, member_order_count, confirmed)
|
||||||
|
注:区域级无 cash_inflow(对齐 v1.2 H6 降级),故与全店版 series 字段少一个 cash_in。
|
||||||
|
|
||||||
|
area_code 必须为非 "all" 的具体业态编码。
|
||||||
|
"""
|
||||||
|
from app.database import get_connection
|
||||||
|
from app.services.fdw_queries import _fdw_context
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("区域日粒度查询连接失败", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _fdw_context(conn, site_id) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT stat_date,
|
||||||
|
COALESCE(gross_amount, 0) AS gross,
|
||||||
|
COALESCE(order_count, 0) AS order_count,
|
||||||
|
COALESCE(member_order_count, 0) AS member_order_count,
|
||||||
|
COALESCE(confirmed_income, 0) AS confirmed
|
||||||
|
FROM app.v_dws_finance_area_daily
|
||||||
|
WHERE area_code = %s
|
||||||
|
AND stat_date >= %s::date
|
||||||
|
AND stat_date <= %s::date
|
||||||
|
ORDER BY stat_date
|
||||||
|
""",
|
||||||
|
(area_code, start_date, end_date),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"区域日粒度查询失败: site_id=%s area=%s", site_id, area_code, exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
active = [
|
||||||
|
(r[0], float(r[1]), int(r[2] or 0), int(r[3] or 0), float(r[4] or 0))
|
||||||
|
for r in rows
|
||||||
|
if float(r[1] or 0) > 0
|
||||||
|
]
|
||||||
|
return active if active else None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_area_unit_economics(
|
||||||
|
series: list[tuple] | None,
|
||||||
|
prev_series: list[tuple] | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""区域级单位经济:客单价 + 日均订单数(含环比)。
|
||||||
|
|
||||||
|
与全店版差异:
|
||||||
|
- 不输出「会员订单占比」(对齐 v1.2 system prompt H6 · 等 DWS 回填完成 + A/B 评估后再开放)
|
||||||
|
- series 字段顺序:(stat_date, gross, order_count, member_order_count, confirmed)
|
||||||
|
|
||||||
|
月初场景(上期样本 < 5 天)附加"样本不足"后缀让 AI 降权引用。
|
||||||
|
"""
|
||||||
|
if not series:
|
||||||
|
return None
|
||||||
|
total_orders = sum(r[2] for r in series)
|
||||||
|
if total_orders <= 0:
|
||||||
|
return None
|
||||||
|
total_gross = sum(r[1] for r in series)
|
||||||
|
total_confirmed = sum(r[4] for r in series)
|
||||||
|
days = len(series)
|
||||||
|
|
||||||
|
price_confirmed = total_confirmed / total_orders
|
||||||
|
price_gross = total_gross / total_orders
|
||||||
|
daily_orders = total_orders / days
|
||||||
|
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"总订单数": total_orders,
|
||||||
|
"日均订单数": round(daily_orders, 1),
|
||||||
|
"客单价_按成交收入": round(price_confirmed, 2),
|
||||||
|
"客单价_按发生额": round(price_gross, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev_series:
|
||||||
|
prev_orders = sum(r[2] for r in prev_series)
|
||||||
|
if prev_orders > 0:
|
||||||
|
prev_days = len(prev_series)
|
||||||
|
prev_gross = sum(r[1] for r in prev_series)
|
||||||
|
prev_confirmed = sum(r[4] for r in prev_series)
|
||||||
|
low_sample = prev_days < 5
|
||||||
|
|
||||||
|
def _pct_change(cur: float, prev: float) -> str:
|
||||||
|
if prev <= 0:
|
||||||
|
return "无上期数据"
|
||||||
|
value = f"{(cur - prev) / prev * 100:+.1f}%"
|
||||||
|
return f"{value}(上期仅 {prev_days} 天,样本不足仅供参考)" if low_sample else value
|
||||||
|
|
||||||
|
out["客单价_按成交收入_环比"] = _pct_change(price_confirmed, prev_confirmed / prev_orders)
|
||||||
|
out["客单价_按发生额_环比"] = _pct_change(price_gross, prev_gross / prev_orders)
|
||||||
|
out["日均订单数_环比"] = _pct_change(daily_orders, prev_orders / prev_days)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_by_weekday_area(series: list[tuple] | None) -> dict | None:
|
||||||
|
"""区域级按星期聚合(无现金流入字段)。
|
||||||
|
|
||||||
|
series 字段顺序:(stat_date, gross, order_count, member_order_count, confirmed)
|
||||||
|
"""
|
||||||
|
if not series or len(series) < _WEEKDAY_MIN_DAYS:
|
||||||
|
return None
|
||||||
|
buckets: dict[int, list[tuple]] = defaultdict(list)
|
||||||
|
for row in series:
|
||||||
|
buckets[row[0].weekday()].append(row)
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for wd in range(7):
|
||||||
|
rows = buckets.get(wd) or []
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
n = len(rows)
|
||||||
|
out[_WEEKDAY_ZH[wd]] = {
|
||||||
|
"日均发生额": round(sum(r[1] for r in rows) / n, 2),
|
||||||
|
"日均订单数": round(sum(r[2] for r in rows) / n, 1),
|
||||||
|
"营业日数": n,
|
||||||
|
}
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_anomaly_days_area(
|
||||||
|
series: list[tuple] | None,
|
||||||
|
) -> list[dict] | None:
|
||||||
|
"""区域级日粒度异常(仅对 gross_amount 做,无现金流入)。
|
||||||
|
|
||||||
|
series 字段顺序:(stat_date, gross, order_count, member_order_count, confirmed)
|
||||||
|
"""
|
||||||
|
if not series or len(series) < _ANOMALY_MIN_DAYS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _scan(idx: int, label: str) -> list[dict]:
|
||||||
|
vals = [row[idx] for row in series]
|
||||||
|
global_mean = sum(vals) / len(vals)
|
||||||
|
if global_mean <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
by_weekday: dict[int, list[float]] = defaultdict(list)
|
||||||
|
for d, *metrics in series:
|
||||||
|
by_weekday[d.weekday()].append(metrics[idx - 1])
|
||||||
|
weekday_mean: dict[int, float] = {
|
||||||
|
wd: (sum(xs) / len(xs)) for wd, xs in by_weekday.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
flagged: list[dict] = []
|
||||||
|
for d, *metrics in series:
|
||||||
|
v = metrics[idx - 1]
|
||||||
|
wd = d.weekday()
|
||||||
|
same_count = len(by_weekday.get(wd, []))
|
||||||
|
if same_count >= _ANOMALY_MIN_SAME_WEEKDAY and weekday_mean[wd] > 0:
|
||||||
|
base = weekday_mean[wd]
|
||||||
|
base_label = f"同{_WEEKDAY_ZH[wd]}均值"
|
||||||
|
else:
|
||||||
|
base = global_mean
|
||||||
|
base_label = "期均"
|
||||||
|
|
||||||
|
deviation = (v - base) / base
|
||||||
|
if abs(deviation) >= _ANOMALY_DEVIATION:
|
||||||
|
flagged.append({
|
||||||
|
"日期": f"{d} {_WEEKDAY_ZH[wd]}",
|
||||||
|
"指标": label,
|
||||||
|
"当日": round(v, 2),
|
||||||
|
"基线": round(base, 2),
|
||||||
|
"基线类型": base_label,
|
||||||
|
"偏离": f"{deviation * 100:+.1f}%",
|
||||||
|
"_abs_dev": abs(deviation),
|
||||||
|
})
|
||||||
|
return flagged
|
||||||
|
|
||||||
|
candidates = _scan(1, "发生额") # 区域级仅发生额做异常(无现金流入)
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(key=lambda x: x["_abs_dev"], reverse=True)
|
||||||
|
out = []
|
||||||
|
for c in candidates[:_ANOMALY_MAX_ITEMS]:
|
||||||
|
c.pop("_abs_dev", None)
|
||||||
|
out.append(c)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_area_share(
|
||||||
|
site_id: int, time_dimension: str, area_confirmed: float,
|
||||||
|
) -> dict | None:
|
||||||
|
"""查全店成交收入 + 上期全店成交收入,派生「区域占比」字段。
|
||||||
|
|
||||||
|
返回:{本区域成交收入, 占全店成交收入, 占比环比}
|
||||||
|
失败或数据不足返回 None。
|
||||||
|
"""
|
||||||
|
board_time = DIMENSION_MAP.get(time_dimension)
|
||||||
|
if not board_time:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
all_board = await get_finance_board(
|
||||||
|
time=board_time, area="all", compare=1, site_id=site_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("区域占比·全店数据查询失败", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
all_overview = (all_board or {}).get("overview") or {}
|
||||||
|
all_confirmed = float(all_overview.get("confirmed_revenue") or 0)
|
||||||
|
if all_confirmed <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
share = area_confirmed / all_confirmed
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"本区域成交收入": round(area_confirmed, 2),
|
||||||
|
"全店成交收入": round(all_confirmed, 2),
|
||||||
|
"占全店成交收入": f"{share * 100:.1f}%",
|
||||||
|
}
|
||||||
|
# 环比:上期区域占比(本轮简化:若 all 的 confirmed_revenue_compare 可用,则给出"全店环比参照"让 AI 自己对比)
|
||||||
|
# 本区域占比环比 = (本期区域占比 − 上期区域占比),需查上期 area board,为避免额外 DB 访问,暂只给出本期占比
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_area_derived_ratios(
|
||||||
|
overview: dict | None, coach_kpi: dict | None, discount_kpi: dict | None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""区域级派生比率:仅「人力成本占成交收入比」「优惠侵蚀率」。
|
||||||
|
|
||||||
|
其他比率(储值卡占比/结余率)区域级无数据,不输出。
|
||||||
|
"""
|
||||||
|
if not isinstance(overview, dict):
|
||||||
|
return None
|
||||||
|
confirmed = float(overview.get("confirmed_revenue") or 0)
|
||||||
|
ratios: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if coach_kpi and confirmed > 0:
|
||||||
|
total_pay = float(coach_kpi.get("人力薪酬合计") or 0)
|
||||||
|
if total_pay > 0:
|
||||||
|
ratios["人力成本占成交收入比"] = round(total_pay / confirmed, 4)
|
||||||
|
|
||||||
|
if discount_kpi and confirmed > 0:
|
||||||
|
total_discount = float(discount_kpi.get("总优惠") or 0)
|
||||||
|
gross = float(overview.get("occurrence") or 0)
|
||||||
|
if gross > 0:
|
||||||
|
ratios["优惠侵蚀率"] = round(total_discount / gross, 4)
|
||||||
|
|
||||||
|
return ratios or None
|
||||||
|
|
||||||
|
|
||||||
|
async def build_prompt(
|
||||||
|
context: dict,
|
||||||
|
cache_svc: Any | None = None, # 兼容统一签名
|
||||||
|
) -> str:
|
||||||
|
"""构建 app2a 区域财务洞察 prompt 字符串。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: site_id, time_dimension, area(area != 'all')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON 序列化的 prompt 字符串,字段已翻译为中文。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: time_dimension 不支持 · area 为 'all' · area 不在白名单
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
site_id = context["site_id"]
|
||||||
|
time_dimension = context["time_dimension"]
|
||||||
|
area = context.get("area")
|
||||||
|
|
||||||
|
if area == "all":
|
||||||
|
raise ValueError("app2a_finance_area 仅处理区域组合 · area='all' 应走 app2_finance")
|
||||||
|
if area not in AREA_LABELS:
|
||||||
|
raise ValueError(f"app2a_finance_area 不支持的区域: {area}")
|
||||||
|
|
||||||
|
board_time = DIMENSION_MAP.get(time_dimension)
|
||||||
|
if not board_time:
|
||||||
|
raise ValueError(f"app2a_finance_area 不支持的时间维度: {time_dimension}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
board_data = await get_finance_board(
|
||||||
|
time=board_time, area=area, compare=1, site_id=site_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"app2a 财务看板查询失败: site_id=%s dimension=%s area=%s",
|
||||||
|
site_id, time_dimension, area, exc_info=True,
|
||||||
|
)
|
||||||
|
board_data = {}
|
||||||
|
|
||||||
|
overview = board_data.get("overview") if isinstance(board_data, dict) else None
|
||||||
|
revenue = board_data.get("revenue") if isinstance(board_data, dict) else None
|
||||||
|
coach = board_data.get("coach_analysis") if isinstance(board_data, dict) else None
|
||||||
|
expense = board_data.get("expense") if isinstance(board_data, dict) else None
|
||||||
|
|
||||||
|
discount_kpi = _build_discount_kpi(revenue, overview)
|
||||||
|
coach_kpi = _build_coach_kpi(coach)
|
||||||
|
expense_kpi = _aggregate_expense(expense)
|
||||||
|
ratios = _build_area_derived_ratios(overview, coach_kpi, discount_kpi)
|
||||||
|
|
||||||
|
# 原始数据 slim 后翻译,供 AI 追溯细节
|
||||||
|
slim_data = _slim(board_data) or {}
|
||||||
|
raw_cn = _translate_keys(slim_data)
|
||||||
|
|
||||||
|
# 对比口径(所有环比字段的前置依赖 · H1)
|
||||||
|
compare_caliber: dict[str, Any] | None = None
|
||||||
|
try:
|
||||||
|
cur_start, cur_end = _calc_date_range(board_time)
|
||||||
|
prev_start, prev_end = _calc_prev_range(board_time, cur_start, cur_end)
|
||||||
|
cur_days = (cur_end - cur_start).days + 1
|
||||||
|
prev_days = (prev_end - prev_start).days + 1
|
||||||
|
compare_caliber = {
|
||||||
|
"当期范围": f"{cur_start} ~ {cur_end}({cur_days} 天)",
|
||||||
|
"对比期范围": f"{prev_start} ~ {prev_end}({prev_days} 天)",
|
||||||
|
"对齐方式": "上期同天数对齐(非整月/整周对比)",
|
||||||
|
"说明": "所有 _环比 / _compare 字段均按上表口径计算;月中调用时对比期会自动截断到与当期相同天数",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.debug("对比口径字段生成失败(不影响主流程)", exc_info=True)
|
||||||
|
|
||||||
|
# 业态说明(v1.2 system prompt H7 引用依据)
|
||||||
|
trait_info = AREA_INDUSTRY_TRAITS.get(area, {})
|
||||||
|
industry_brief = {
|
||||||
|
"区域编码": area,
|
||||||
|
"区域名称": AREA_LABELS.get(area, area),
|
||||||
|
"业态特征": trait_info.get("trait", "—"),
|
||||||
|
"典型对比项": trait_info.get("peer", "—"),
|
||||||
|
}
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"当前时间": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"门店编号": site_id,
|
||||||
|
"时间维度": DIMENSION_LABELS.get(time_dimension, time_dimension),
|
||||||
|
"区域": AREA_LABELS.get(area, area),
|
||||||
|
**({"对比口径": compare_caliber} if compare_caliber else {}),
|
||||||
|
"业态说明": industry_brief,
|
||||||
|
"核心KPI": {
|
||||||
|
"发生额": float((overview or {}).get("occurrence") or 0),
|
||||||
|
"发生额环比": (overview or {}).get("occurrence_compare") or "持平",
|
||||||
|
"成交收入": float((overview or {}).get("confirmed_revenue") or 0),
|
||||||
|
"成交收入环比": (overview or {}).get("confirmed_revenue_compare") or "持平",
|
||||||
|
# 区域级无现金流入数据(v1.2 H6 降级),不输出现金相关 KPI
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 派生比率(仅 2 项)
|
||||||
|
if ratios:
|
||||||
|
payload["派生比率"] = ratios
|
||||||
|
|
||||||
|
# 区域占比(需异步查全店)
|
||||||
|
area_confirmed = float((overview or {}).get("confirmed_revenue") or 0)
|
||||||
|
if area_confirmed > 0:
|
||||||
|
area_share = await _fetch_area_share(site_id, time_dimension, area_confirmed)
|
||||||
|
if area_share:
|
||||||
|
payload["区域占比"] = area_share
|
||||||
|
|
||||||
|
# 优惠构成(复用全店版逻辑)
|
||||||
|
if discount_kpi:
|
||||||
|
payload["优惠构成"] = discount_kpi
|
||||||
|
|
||||||
|
# 助教成本画像(复用全店版逻辑 · 空则整块不注入 · 符合 v1.2 H6)
|
||||||
|
if coach_kpi:
|
||||||
|
payload["助教成本"] = coach_kpi
|
||||||
|
|
||||||
|
# 支出概况(区域级仅助教支出有效,v1.2 禁谈运营/固定/平台支出 · 但注入给 AI 追溯)
|
||||||
|
# 注:v1.2 system prompt 明确要求 D 板块禁谈这三类,AI 自会规避
|
||||||
|
if expense_kpi:
|
||||||
|
payload["支出概况"] = expense_kpi
|
||||||
|
|
||||||
|
# 日粒度派生(区域级)
|
||||||
|
try:
|
||||||
|
start_date, end_date = _calc_date_range(board_time)
|
||||||
|
series = _fetch_area_daily_series(
|
||||||
|
site_id, str(start_date), str(end_date), area_code=area,
|
||||||
|
)
|
||||||
|
prev_series: list[tuple] | None = None
|
||||||
|
try:
|
||||||
|
prev_start, prev_end = _calc_prev_range(board_time, start_date, end_date)
|
||||||
|
prev_series = _fetch_area_daily_series(
|
||||||
|
site_id, str(prev_start), str(prev_end), area_code=area,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("区域上期 series 查询失败,客单价环比字段将省略", exc_info=True)
|
||||||
|
|
||||||
|
if series:
|
||||||
|
unit_econ = _build_area_unit_economics(series, prev_series=prev_series)
|
||||||
|
if unit_econ:
|
||||||
|
payload["单位经济"] = unit_econ
|
||||||
|
by_weekday = _aggregate_by_weekday_area(series)
|
||||||
|
if by_weekday:
|
||||||
|
payload["按星期聚合"] = by_weekday
|
||||||
|
anomalies = _detect_anomaly_days_area(series)
|
||||||
|
if anomalies:
|
||||||
|
payload["日粒度异常"] = anomalies
|
||||||
|
except Exception:
|
||||||
|
logger.debug("区域日粒度派生字段注入失败(不影响主流程)", exc_info=True)
|
||||||
|
|
||||||
|
# 行业基线
|
||||||
|
payload["行业基线"] = INDUSTRY_BASELINES
|
||||||
|
|
||||||
|
# 原始指标(slim 后的区域子集)
|
||||||
|
payload["原始指标"] = raw_cn
|
||||||
|
|
||||||
|
if not board_data:
|
||||||
|
payload["数据缺失提示"] = "区域财务看板数据获取失败,请基于已有缓存或常识分析"
|
||||||
|
|
||||||
|
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||||
@@ -37,6 +37,7 @@ class SSEEvent(BaseModel):
|
|||||||
|
|
||||||
class CacheTypeEnum(str, enum.Enum):
|
class CacheTypeEnum(str, enum.Enum):
|
||||||
APP2_FINANCE = "app2_finance"
|
APP2_FINANCE = "app2_finance"
|
||||||
|
APP2A_FINANCE_AREA = "app2a_finance_area" # 2026-04-23 新增:区域财务洞察(64 组合)
|
||||||
APP3_CLUE = "app3_clue"
|
APP3_CLUE = "app3_clue"
|
||||||
APP4_ANALYSIS = "app4_analysis"
|
APP4_ANALYSIS = "app4_analysis"
|
||||||
APP5_TACTICS = "app5_tactics"
|
APP5_TACTICS = "app5_tactics"
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ from typing import Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
from app.auth.dependencies import CurrentUser
|
from app.auth.dependencies import CurrentUser, get_current_user
|
||||||
from app.middleware.permission import require_permission
|
from app.middleware.permission import require_permission # 保留给可能的其他依赖
|
||||||
from app.schemas.admin_ai import (
|
from app.schemas.admin_ai import (
|
||||||
AlertActionResponse,
|
AlertActionResponse,
|
||||||
AlertListResponse,
|
AlertListResponse,
|
||||||
@@ -40,11 +40,18 @@ from app.schemas.admin_ai import (
|
|||||||
CacheInvalidateRequest,
|
CacheInvalidateRequest,
|
||||||
CacheInvalidateResponse,
|
CacheInvalidateResponse,
|
||||||
DashboardResponse,
|
DashboardResponse,
|
||||||
|
ManualTriggerRequest,
|
||||||
|
ManualTriggerResponse,
|
||||||
|
PrewarmProgressResponse,
|
||||||
RetryResponse,
|
RetryResponse,
|
||||||
|
RunAppRequest,
|
||||||
|
RunAppResponse,
|
||||||
RunLogDetailResponse,
|
RunLogDetailResponse,
|
||||||
RunLogListResponse,
|
RunLogListResponse,
|
||||||
|
TriggerItem,
|
||||||
TriggerJobDetailResponse,
|
TriggerJobDetailResponse,
|
||||||
TriggerJobListResponse,
|
TriggerJobListResponse,
|
||||||
|
TriggerUpdateRequest,
|
||||||
)
|
)
|
||||||
from app.services.ai.admin_service import AdminAIService
|
from app.services.ai.admin_service import AdminAIService
|
||||||
|
|
||||||
@@ -62,18 +69,43 @@ _admin_svc = AdminAIService()
|
|||||||
|
|
||||||
def _require_admin():
|
def _require_admin():
|
||||||
"""
|
"""
|
||||||
管理端依赖:要求 JWT status=approved 且角色包含 site_admin 或 tenant_admin。
|
管理端依赖:直接从 JWT 读 roles 判定是否 admin(site_admin / tenant_admin / super_admin)。
|
||||||
|
|
||||||
|
2026-04-21:改为不依赖 auth.users.status 查询(admin-web 登录用 admin_users 表,
|
||||||
|
与 require_permission 走的 auth.users 不是同一张表)。status 实时校验通过 admin_users.is_active。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def _dependency(
|
async def _dependency(
|
||||||
user: CurrentUser = Depends(require_permission()),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> CurrentUser:
|
) -> CurrentUser:
|
||||||
admin_roles = {"site_admin", "tenant_admin"}
|
admin_roles = {"site_admin", "tenant_admin", "super_admin"}
|
||||||
if not admin_roles.intersection(user.roles):
|
if not admin_roles.intersection(user.roles):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="需要管理员权限(site_admin 或 tenant_admin)",
|
detail="需要管理员权限(site_admin / tenant_admin / super_admin)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 实时校验 admin_users 表的 is_active(若 user_id 在该表)
|
||||||
|
from app.database import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT is_active FROM admin_users WHERE id = %s",
|
||||||
|
(user.user_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 在 admin_users 中找到且未激活 → 拒绝
|
||||||
|
if row is not None and not row[0]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="管理员账号已禁用",
|
||||||
|
)
|
||||||
|
# 不在 admin_users 中但 JWT 带 admin 角色(如 xcx 用户临时升权),也允许通过
|
||||||
return user
|
return user
|
||||||
|
|
||||||
return _dependency
|
return _dependency
|
||||||
@@ -85,10 +117,18 @@ def _require_admin():
|
|||||||
@router.get("/dashboard", response_model=DashboardResponse)
|
@router.get("/dashboard", response_model=DashboardResponse)
|
||||||
async def get_dashboard(
|
async def get_dashboard(
|
||||||
site_id: Optional[int] = Query(None, description="门店 ID 筛选"),
|
site_id: Optional[int] = Query(None, description="门店 ID 筛选"),
|
||||||
|
range_days: Optional[int] = Query(None, ge=1, le=365, description="回溯天数:1=今日 / 3 / 7 / 10"),
|
||||||
|
date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD(与 date_to 成对使用)"),
|
||||||
|
date_to: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
|
||||||
user: CurrentUser = Depends(_require_admin()),
|
user: CurrentUser = Depends(_require_admin()),
|
||||||
) -> DashboardResponse:
|
) -> DashboardResponse:
|
||||||
"""总览统计(支持 site_id 筛选)。"""
|
"""总览统计(支持 site_id + 时间范围筛选)。"""
|
||||||
data = await _admin_svc.get_dashboard(site_id=site_id)
|
data = await _admin_svc.get_dashboard(
|
||||||
|
site_id=site_id,
|
||||||
|
range_days=range_days,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
return DashboardResponse(**data)
|
return DashboardResponse(**data)
|
||||||
|
|
||||||
|
|
||||||
@@ -292,3 +332,152 @@ async def ignore_alert(
|
|||||||
"""忽略告警:alert_status → ignored。"""
|
"""忽略告警:alert_status → ignored。"""
|
||||||
new_status = await _admin_svc.ignore_alert(log_id)
|
new_status = await _admin_svc.ignore_alert(log_id)
|
||||||
return AlertActionResponse(id=log_id, alert_status=new_status)
|
return AlertActionResponse(id=log_id, alert_status=new_status)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 按需执行单个 App(admin-web 重新生成按钮用)──────────
|
||||||
|
|
||||||
|
|
||||||
|
_SUPPORTED_APP_TYPES = {
|
||||||
|
"app2_finance",
|
||||||
|
"app2a_finance_area", # 2026-04-23 新增:区域财务洞察
|
||||||
|
"app3_clue",
|
||||||
|
"app4_analysis",
|
||||||
|
"app5_tactics",
|
||||||
|
"app6_note",
|
||||||
|
"app7_customer",
|
||||||
|
"app8_consolidation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run/{app_type}", response_model=RunAppResponse)
|
||||||
|
async def run_single_app(
|
||||||
|
app_type: str,
|
||||||
|
body: RunAppRequest,
|
||||||
|
user: CurrentUser = Depends(_require_admin()),
|
||||||
|
) -> RunAppResponse:
|
||||||
|
"""按需执行单个 App,跳过链路编排。
|
||||||
|
|
||||||
|
使用场景:admin-web 缓存详情页 / 告警页的"重新生成"按钮。
|
||||||
|
熔断/限流/预算检查由 dispatcher._run_step 自动执行。
|
||||||
|
结果写入 ai_cache,失败不抛异常,通过 success=False 返回。
|
||||||
|
"""
|
||||||
|
if app_type not in _SUPPORTED_APP_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"不支持的 app_type: {app_type};支持 {sorted(_SUPPORTED_APP_TYPES)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.ai.dispatcher import get_dispatcher
|
||||||
|
|
||||||
|
try:
|
||||||
|
dispatcher = get_dispatcher()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
context = body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await dispatcher.run_single_app(
|
||||||
|
app_type=app_type,
|
||||||
|
context=context,
|
||||||
|
triggered_by=f"admin:{user.user_id}",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return RunAppResponse(
|
||||||
|
app_type=app_type,
|
||||||
|
success=False,
|
||||||
|
error="AI 调用失败(详情见 ai_run_logs,可能为熔断/限流/预算/超时)",
|
||||||
|
)
|
||||||
|
return RunAppResponse(app_type=app_type, success=True, result=result)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 触发器管理(biz.trigger_jobs)─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/triggers", response_model=list[TriggerItem])
|
||||||
|
async def list_triggers(
|
||||||
|
_user: CurrentUser = Depends(_require_admin()),
|
||||||
|
) -> list[TriggerItem]:
|
||||||
|
"""列出所有 AI 相关触发器(job_type=ai_* 或 task_generator)。"""
|
||||||
|
rows = await _admin_svc.list_triggers()
|
||||||
|
return [TriggerItem(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/triggers/{trigger_id}", response_model=TriggerItem)
|
||||||
|
async def update_trigger(
|
||||||
|
trigger_id: int,
|
||||||
|
body: TriggerUpdateRequest,
|
||||||
|
_user: CurrentUser = Depends(_require_admin()),
|
||||||
|
) -> TriggerItem:
|
||||||
|
"""更新触发器:启用/禁用、修改 cron 表达式、修改描述。"""
|
||||||
|
try:
|
||||||
|
row = await _admin_svc.update_trigger(
|
||||||
|
trigger_id,
|
||||||
|
status_new=body.status,
|
||||||
|
cron_expression=body.cron_expression,
|
||||||
|
description=body.description,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return TriggerItem(**row)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 预热进度查询 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prewarm/progress", response_model=PrewarmProgressResponse)
|
||||||
|
async def get_prewarm_progress(
|
||||||
|
site_id: int = Query(..., description="门店 ID"),
|
||||||
|
_user: CurrentUser = Depends(_require_admin()),
|
||||||
|
) -> PrewarmProgressResponse:
|
||||||
|
"""查询 app2_finance 72 组合预热进度(done / missing)。"""
|
||||||
|
data = await _admin_svc.get_prewarm_progress(site_id)
|
||||||
|
return PrewarmProgressResponse(**data)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 手动事件触发(跨越去重)──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trigger-event", response_model=ManualTriggerResponse)
|
||||||
|
async def manual_trigger_event(
|
||||||
|
body: ManualTriggerRequest,
|
||||||
|
user: CurrentUser = Depends(_require_admin()),
|
||||||
|
) -> ManualTriggerResponse:
|
||||||
|
"""手动触发 AI 事件链,默认 is_forced=True 跳过去重。
|
||||||
|
|
||||||
|
事件类型:consumption / dws_completed / note_created / task_assigned
|
||||||
|
"""
|
||||||
|
from app.ai.dispatcher import TriggerEvent, get_dispatcher
|
||||||
|
|
||||||
|
valid_events = {"consumption", "dws_completed", "note_created", "task_assigned"}
|
||||||
|
if body.event_type not in valid_events:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"非法 event_type: {body.event_type};支持 {sorted(valid_events)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dispatcher = get_dispatcher()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
payload = dict(body.payload or {})
|
||||||
|
if body.assistant_id is not None:
|
||||||
|
payload.setdefault("assistant_id", body.assistant_id)
|
||||||
|
|
||||||
|
event = TriggerEvent(
|
||||||
|
event_type=body.event_type,
|
||||||
|
site_id=body.site_id,
|
||||||
|
member_id=body.member_id,
|
||||||
|
payload=payload,
|
||||||
|
is_forced=body.is_forced,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"admin 手动触发事件: user=%s event=%s site_id=%s member_id=%s forced=%s",
|
||||||
|
user.user_id, body.event_type, body.site_id, body.member_id, body.is_forced,
|
||||||
|
)
|
||||||
|
job_id = await dispatcher.handle_trigger(event)
|
||||||
|
return ManualTriggerResponse(trigger_job_id=job_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user