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:
Neo
2026-04-22 21:55:26 +08:00
parent 76a23639ee
commit 8638ecad2a
7 changed files with 1399 additions and 237 deletions

2
.env
View File

@@ -127,6 +127,8 @@ DASHSCOPE_APP_ID_5_TACTICS=46f54e6053df4bb0b83be29366025cf6
DASHSCOPE_APP_ID_6_NOTE=025bb344146b4e4e8be30c444adab3b4
DASHSCOPE_APP_ID_7_CUSTOMER=df35e06991b24d49971c03c6428a9c87
DASHSCOPE_APP_ID_8_CONSOLIDATE=407dfb89283b4196934eec5fefe3ebc2
# 应用 2a区域财务洞察64 组合 · area != 'all' · 板块 C/E 重分工 · 新增 H7 业态特征硬约束)
DASHSCOPE_APP_ID_2A_FINANCE_AREA=0ae965029bc54706bcff44f511ac716b
# 应用 9Session 日志摘要生成Kiro agent_on_stop + batch_generate_summaries 使用)
DASHSCOPE_APP_ID_SUMMARY=e0cf8913b1ee4a4eb9464cc1ee0bf300

View File

@@ -18,6 +18,7 @@ class AIConfig:
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID可选
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
app_id_2a_finance_area: str # DASHSCOPE_APP_ID_2A_FINANCE_AREA2026-04-23 新增,区域财务洞察)
app_id_3_clue: str # DASHSCOPE_APP_ID_3_CLUE
app_id_4_analysis: str # DASHSCOPE_APP_ID_4_ANALYSIS
app_id_5_tactics: str # DASHSCOPE_APP_ID_5_TACTICS
@@ -37,6 +38,7 @@ class AIConfig:
"DASHSCOPE_API_KEY": "api_key",
"DASHSCOPE_APP_ID_1_CHAT": "app_id_1_chat",
"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_4_ANALYSIS": "app_id_4_analysis",
"DASHSCOPE_APP_ID_5_TACTICS": "app_id_5_tactics",

File diff suppressed because it is too large Load Diff

View File

@@ -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",
]

View 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, areaarea != '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)

View File

@@ -37,6 +37,7 @@ class SSEEvent(BaseModel):
class CacheTypeEnum(str, enum.Enum):
APP2_FINANCE = "app2_finance"
APP2A_FINANCE_AREA = "app2a_finance_area" # 2026-04-23 新增区域财务洞察64 组合)
APP3_CLUE = "app3_clue"
APP4_ANALYSIS = "app4_analysis"
APP5_TACTICS = "app5_tactics"

View File

@@ -27,8 +27,8 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.auth.dependencies import CurrentUser
from app.middleware.permission import require_permission
from app.auth.dependencies import CurrentUser, get_current_user
from app.middleware.permission import require_permission # 保留给可能的其他依赖
from app.schemas.admin_ai import (
AlertActionResponse,
AlertListResponse,
@@ -40,11 +40,18 @@ from app.schemas.admin_ai import (
CacheInvalidateRequest,
CacheInvalidateResponse,
DashboardResponse,
ManualTriggerRequest,
ManualTriggerResponse,
PrewarmProgressResponse,
RetryResponse,
RunAppRequest,
RunAppResponse,
RunLogDetailResponse,
RunLogListResponse,
TriggerItem,
TriggerJobDetailResponse,
TriggerJobListResponse,
TriggerUpdateRequest,
)
from app.services.ai.admin_service import AdminAIService
@@ -62,18 +69,43 @@ _admin_svc = AdminAIService()
def _require_admin():
"""
管理端依赖:要求 JWT status=approved 且角色包含 site_admin tenant_admin。
管理端依赖:直接从 JWT 读 roles 判定是否 adminsite_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(
user: CurrentUser = Depends(require_permission()),
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
admin_roles = {"site_admin", "tenant_admin"}
admin_roles = {"site_admin", "tenant_admin", "super_admin"}
if not admin_roles.intersection(user.roles):
raise HTTPException(
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 _dependency
@@ -85,10 +117,18 @@ def _require_admin():
@router.get("/dashboard", response_model=DashboardResponse)
async def get_dashboard(
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()),
) -> DashboardResponse:
"""总览统计(支持 site_id 筛选)。"""
data = await _admin_svc.get_dashboard(site_id=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)
@@ -292,3 +332,152 @@ async def ignore_alert(
"""忽略告警alert_status → ignored。"""
new_status = await _admin_svc.ignore_alert(log_id)
return AlertActionResponse(id=log_id, alert_status=new_status)
# ── 按需执行单个 Appadmin-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)