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

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)