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

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

3. schemas.py: CacheTypeEnum 增加 APP2A_FINANCE_AREA

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

5. admin_ai.py: _SUPPORTED_APP_TYPES 加 'app2a_finance_area'

6. prompts/__init__.py: 导出 build_app2a_area_prompt

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

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

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

499 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""应用 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)