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>
484 lines
18 KiB
Python
484 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
管理端 — AI 监控后台路由。
|
||
|
||
端点清单(13 个,全部需要 JWT + admin 角色):
|
||
- GET /api/admin/ai/dashboard — 总览统计
|
||
- GET /api/admin/ai/trigger-jobs — 调度任务分页列表
|
||
- GET /api/admin/ai/trigger-jobs/{job_id} — 调度任务详情
|
||
- POST /api/admin/ai/trigger-jobs/{job_id}/retry — 手动重跑
|
||
- GET /api/admin/ai/run-logs — 调用记录分页列表
|
||
- GET /api/admin/ai/run-logs/{log_id} — 调用记录详情
|
||
- POST /api/admin/ai/cache/invalidate — 缓存失效
|
||
- GET /api/admin/ai/budget — Token 预算
|
||
- POST /api/admin/ai/batch-run — 创建批量执行(返回预估)
|
||
- POST /api/admin/ai/batch-run/confirm — 确认批量执行
|
||
- GET /api/admin/ai/alerts — 告警列表
|
||
- POST /api/admin/ai/alerts/{log_id}/ack — 确认告警
|
||
- POST /api/admin/ai/alerts/{log_id}/ignore — 忽略告警
|
||
|
||
需求: A1.1, A2.1, A2.4, A3.1, A4.1, A4.3, A5.1, A6.1, A7.1, A7.3, A8.1, A8.2, A8.3, A9.1, A9.2, A9.3
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
|
||
from app.auth.dependencies import CurrentUser, get_current_user
|
||
from app.middleware.permission import require_permission # 保留给可能的其他依赖
|
||
from app.schemas.admin_ai import (
|
||
AlertActionResponse,
|
||
AlertListResponse,
|
||
BatchRunConfirm,
|
||
BatchRunConfirmResponse,
|
||
BatchRunEstimate,
|
||
BatchRunRequest,
|
||
BudgetResponse,
|
||
CacheInvalidateRequest,
|
||
CacheInvalidateResponse,
|
||
DashboardResponse,
|
||
ManualTriggerRequest,
|
||
ManualTriggerResponse,
|
||
PrewarmProgressResponse,
|
||
RetryResponse,
|
||
RunAppRequest,
|
||
RunAppResponse,
|
||
RunLogDetailResponse,
|
||
RunLogListResponse,
|
||
TriggerItem,
|
||
TriggerJobDetailResponse,
|
||
TriggerJobListResponse,
|
||
TriggerUpdateRequest,
|
||
)
|
||
from app.services.ai.admin_service import AdminAIService
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/admin/ai", tags=["admin-ai"])
|
||
|
||
|
||
# ── 模块级服务实例 ────────────────────────────────────────
|
||
_admin_svc = AdminAIService()
|
||
|
||
|
||
# ── 权限依赖 ──────────────────────────────────────────────
|
||
|
||
|
||
def _require_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(
|
||
user: CurrentUser = Depends(get_current_user),
|
||
) -> CurrentUser:
|
||
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 / 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
|
||
|
||
|
||
# ── Dashboard ─────────────────────────────────────────────
|
||
|
||
|
||
@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,
|
||
range_days=range_days,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
)
|
||
return DashboardResponse(**data)
|
||
|
||
|
||
# ── 调度任务 ──────────────────────────────────────────────
|
||
|
||
|
||
@router.get("/trigger-jobs", response_model=TriggerJobListResponse)
|
||
async def list_trigger_jobs(
|
||
event_type: Optional[str] = Query(None),
|
||
status_filter: Optional[str] = Query(None, alias="status"),
|
||
site_id: Optional[int] = Query(None),
|
||
date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD"),
|
||
date_to: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD"),
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=100),
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> TriggerJobListResponse:
|
||
"""调度任务分页列表。"""
|
||
filters: dict = {}
|
||
if event_type is not None:
|
||
filters["event_type"] = event_type
|
||
if status_filter is not None:
|
||
filters["status"] = status_filter
|
||
if site_id is not None:
|
||
filters["site_id"] = site_id
|
||
if date_from is not None:
|
||
filters["date_from"] = date_from
|
||
if date_to is not None:
|
||
filters["date_to"] = date_to
|
||
|
||
data = await _admin_svc.list_trigger_jobs(filters, page=page, page_size=page_size)
|
||
return TriggerJobListResponse(**data)
|
||
|
||
|
||
@router.get("/trigger-jobs/{job_id}", response_model=TriggerJobDetailResponse)
|
||
async def get_trigger_job(
|
||
job_id: int,
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> TriggerJobDetailResponse:
|
||
"""调度任务详情。"""
|
||
data = await _admin_svc.get_trigger_job(job_id)
|
||
if data is None:
|
||
raise HTTPException(status_code=404, detail="调度任务不存在")
|
||
return TriggerJobDetailResponse(**data)
|
||
|
||
|
||
@router.post("/trigger-jobs/{job_id}/retry", response_model=RetryResponse)
|
||
async def retry_trigger_job(
|
||
job_id: int,
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> RetryResponse:
|
||
"""手动重跑:创建新 trigger_job(is_forced=true),异步执行。"""
|
||
try:
|
||
new_job_id = await _admin_svc.retry_trigger_job(job_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
return RetryResponse(trigger_job_id=new_job_id, status="pending")
|
||
|
||
|
||
# ── 调用记录 ──────────────────────────────────────────────
|
||
|
||
|
||
@router.get("/run-logs", response_model=RunLogListResponse)
|
||
async def list_run_logs(
|
||
app_type: Optional[str] = Query(None),
|
||
status_filter: Optional[str] = Query(None, alias="status"),
|
||
trigger_type: Optional[str] = Query(None),
|
||
site_id: Optional[int] = Query(None),
|
||
date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD"),
|
||
date_to: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD"),
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=100),
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> RunLogListResponse:
|
||
"""调用记录分页列表。"""
|
||
filters: dict = {}
|
||
if app_type is not None:
|
||
filters["app_type"] = app_type
|
||
if status_filter is not None:
|
||
filters["status"] = status_filter
|
||
if trigger_type is not None:
|
||
filters["trigger_type"] = trigger_type
|
||
if site_id is not None:
|
||
filters["site_id"] = site_id
|
||
if date_from is not None:
|
||
filters["date_from"] = date_from
|
||
if date_to is not None:
|
||
filters["date_to"] = date_to
|
||
|
||
data = await _admin_svc.list_run_logs(filters, page=page, page_size=page_size)
|
||
return RunLogListResponse(**data)
|
||
|
||
|
||
@router.get("/run-logs/{log_id}", response_model=RunLogDetailResponse)
|
||
async def get_run_log(
|
||
log_id: int,
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> RunLogDetailResponse:
|
||
"""调用记录详情(含完整 prompt/response/error,不脱敏)。"""
|
||
data = await _admin_svc.get_run_log(log_id)
|
||
if data is None:
|
||
raise HTTPException(status_code=404, detail="调用记录不存在")
|
||
return RunLogDetailResponse(**data)
|
||
|
||
|
||
# ── 缓存管理 ──────────────────────────────────────────────
|
||
|
||
|
||
@router.post("/cache/invalidate", response_model=CacheInvalidateResponse)
|
||
async def invalidate_cache(
|
||
body: CacheInvalidateRequest,
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> CacheInvalidateResponse:
|
||
"""批量缓存失效:将匹配条件的 ai_cache.status 设为 invalidated。"""
|
||
affected = await _admin_svc.invalidate_cache(
|
||
site_id=body.site_id,
|
||
app_type=body.app_type,
|
||
member_id=body.member_id,
|
||
)
|
||
return CacheInvalidateResponse(affected_count=affected)
|
||
|
||
|
||
# ── Token 预算 ────────────────────────────────────────────
|
||
|
||
|
||
@router.get("/budget", response_model=BudgetResponse)
|
||
async def get_budget(
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> BudgetResponse:
|
||
"""Token 预算使用情况:日/月已用量、上限、百分比。"""
|
||
data = await _admin_svc.get_budget()
|
||
return BudgetResponse(**data)
|
||
|
||
|
||
# ── 批量执行 ──────────────────────────────────────────────
|
||
|
||
|
||
@router.post("/batch-run", response_model=BatchRunEstimate)
|
||
async def create_batch_run(
|
||
body: BatchRunRequest,
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> BatchRunEstimate:
|
||
"""创建批量执行请求,返回预估(不立即执行)。"""
|
||
data = await _admin_svc.estimate_batch(
|
||
app_types=body.app_types,
|
||
member_ids=body.member_ids,
|
||
site_id=body.site_id,
|
||
)
|
||
return BatchRunEstimate(**data)
|
||
|
||
|
||
@router.post("/batch-run/confirm", response_model=BatchRunConfirmResponse)
|
||
async def confirm_batch_run(
|
||
body: BatchRunConfirm,
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> BatchRunConfirmResponse:
|
||
"""确认批量执行,后台异步执行。"""
|
||
try:
|
||
await _admin_svc.confirm_batch(batch_id=body.batch_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
return BatchRunConfirmResponse(status="started")
|
||
|
||
|
||
# ── 告警管理 ──────────────────────────────────────────────
|
||
|
||
|
||
@router.get("/alerts", response_model=AlertListResponse)
|
||
async def list_alerts(
|
||
alert_status: Optional[str] = Query(None, description="pending / acknowledged / ignored"),
|
||
site_id: Optional[int] = Query(None),
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=100),
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> AlertListResponse:
|
||
"""告警列表(ai_run_logs WHERE status IN ('failed','timeout','circuit_open'))。"""
|
||
data = await _admin_svc.list_alerts(
|
||
alert_status=alert_status,
|
||
site_id=site_id,
|
||
page=page,
|
||
page_size=page_size,
|
||
)
|
||
return AlertListResponse(**data)
|
||
|
||
|
||
@router.post("/alerts/{log_id}/ack", response_model=AlertActionResponse)
|
||
async def ack_alert(
|
||
log_id: int,
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> AlertActionResponse:
|
||
"""确认告警:alert_status → acknowledged。"""
|
||
new_status = await _admin_svc.ack_alert(log_id)
|
||
return AlertActionResponse(id=log_id, alert_status=new_status)
|
||
|
||
|
||
@router.post("/alerts/{log_id}/ignore", response_model=AlertActionResponse)
|
||
async def ignore_alert(
|
||
log_id: int,
|
||
user: CurrentUser = Depends(_require_admin()),
|
||
) -> AlertActionResponse:
|
||
"""忽略告警:alert_status → ignored。"""
|
||
new_status = await _admin_svc.ignore_alert(log_id)
|
||
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)
|