"""运营告警引擎 + 指标看板(交付报告 P2-4 / P2-3)。 告警:周期性巡检四类风险,写入 Notification(按 dedup_key 10 分钟去重, 避免每次轮询都刷屏): 1. 死信任务积压(队列 dead > 0) 2. 磁盘剩余空间不足(< 2 GB) 3. 点数池偏低(全局余额 < 1000 点数) 4. 任务失败段存在且超过 30 分钟未处理 看板:/api/ops/dashboard 一屏返回队列、成本、知识库、Provider 健康度。 """ from __future__ import annotations import shutil from datetime import datetime, timedelta from sqlmodel import Session, select from .models import Campaign, Notification, PointAccount, TaskQueueItem, Segment, now from .config import DATA_DIR ALERT_DEDUP_WINDOW_MINUTES = 10 DISK_MIN_FREE_GB = 2.0 POINTS_LOW_THRESHOLD = 1000 # 充值提醒阈值:余额低于此值(或低于单任务预估的倍数)即提醒充值。默认 100 万点(=1000 万 Token) POINTS_RECHARGE_ALERT_THRESHOLD = 1_000_000 def _parse_ts(value: str) -> datetime | None: try: return datetime.fromisoformat(str(value or "").replace(" ", "T", 1)) except (ValueError, TypeError): return None def _recent_duplicate(session: Session, dedup_key: str) -> bool: cutoff = datetime.now() - timedelta(minutes=ALERT_DEDUP_WINDOW_MINUTES) existing = session.exec(select(Notification).where( Notification.type == "alert", Notification.message.contains(dedup_key), )).all() for row in existing: created = _parse_ts(row.created_at) if created and created >= cutoff: return True return False def raise_alert(session: Session, dedup_key: str, message: str) -> bool: """写一条告警通知;去重窗口内相同 dedup_key 不重复发。返回是否真的发出。""" if _recent_duplicate(session, dedup_key): return False session.add(Notification(type="alert", ref_id=0, message=f"[{dedup_key}] {message}")) session.commit() return True def run_alert_sweep(session: Session) -> list[str]: """执行一轮巡检,返回本轮真正发出的告警列表。""" fired: list[str] = [] # 1. 死信积压 dead_rows = session.exec(select(TaskQueueItem).where(TaskQueueItem.status == "dead")).all() if dead_rows: if raise_alert(session, "queue-dead", f"队列告警:{len(dead_rows)} 个任务进入死信队列,请在运营看板查看并重放"): fired.append("queue-dead") # 2. 磁盘空间 try: free_gb = shutil.disk_usage(DATA_DIR).free / 1024 ** 3 if free_gb < DISK_MIN_FREE_GB: if raise_alert(session, "disk-low", f"磁盘告警:数据目录剩余 {free_gb:.1f} GB(低于 {DISK_MIN_FREE_GB} GB),请清理或扩容"): fired.append("disk-low") except OSError: pass # 3. 点数池偏低(组织 1 的全部账户合计) accounts = session.exec(select(PointAccount)).all() total_points = sum(int(a.balance_points or 0) for a in accounts) if total_points < POINTS_LOW_THRESHOLD: if raise_alert(session, "points-low", f"点数告警:全局点数余额仅 {total_points}(低于 {POINTS_LOW_THRESHOLD}),AI 调用即将暂停"): fired.append("points-low") # 3.5 充值提醒:余额低于充值提醒阈值(比"即将暂停"更早预警) if total_points < POINTS_RECHARGE_ALERT_THRESHOLD: if raise_alert(session, "points-recharge", f"充值提醒:当前点数余额 {total_points},已低于建议阈值 {POINTS_RECHARGE_ALERT_THRESHOLD}(约还可生成 {max(total_points // 30000, 0)} 段内容),请及时充值"): fired.append("points-recharge") # 4. 失败段滞留(存在失败任务且 30 分钟未处理) campaigns = session.exec(select(Campaign).where(Campaign.has_failure == True)).all() # noqa: E712 stale_cutoff = (datetime.now() - timedelta(minutes=30)).isoformat() for campaign in campaigns: # Campaign 没有 updated_at 字段;失败段本身有更新时间,取它与创建时间的较新者。 segment_times = [str(item.updated_at or "") for item in session.exec( select(Segment).where(Segment.campaign_id == campaign.id)).all()] updated = max([str(campaign.created_at or "")] + segment_times) if updated and updated < stale_cutoff: if raise_alert(session, f"campaign-failure:{campaign.id}", f"任务告警:「{campaign.name}」存在失败段且超过 30 分钟未处理,请到任务看板重试或跳过"): fired.append(f"campaign-failure:{campaign.id}") return fired def ops_dashboard(session: Session, *, owner_user_id: int | None = None, organization_id: int | None = None) -> dict: """一屏运营数据:队列/任务/成本/知识库/Provider 健康。 owner_user_id 传入时按用户隔离:只统计该用户自己的任务、用量、告警; None(管理员)看全组织。 """ from .task_queue import queue_stats from .kb_metrics import kb_metrics_overview from .models import ModelProvider, UsageEvent from . import backup as backup_mod is_admin = owner_user_id is None queue = queue_stats(session, TaskQueueItem) campaign_query = select(Campaign) if organization_id is not None: campaign_query = campaign_query.where(Campaign.organization_id == int(organization_id)) if not is_admin: campaign_query = campaign_query.where(Campaign.owner_user_id == int(owner_user_id)) campaigns = session.exec(campaign_query).all() active = sum(1 for c in campaigns if c.status in ("running", "parsed", "composing_ready", "human_review")) failed = sum(1 for c in campaigns if c.has_failure) done = sum(1 for c in campaigns if c.status == "done") # 近 7 天用量(按用户隔离) cutoff = (datetime.now() - timedelta(days=7)).isoformat() events_query = select(UsageEvent).where(UsageEvent.created_at >= cutoff) if organization_id is not None: events_query = events_query.where(UsageEvent.organization_id == int(organization_id)) if not is_admin: events_query = events_query.where(UsageEvent.user_id == int(owner_user_id)) events = session.exec(events_query).all() amount_minor = sum(int(e.amount_minor or 0) for e in events) tokens = sum(int(e.quantity or 0) for e in events if e.service_type == "llm") kb = kb_metrics_overview(session, owner_user_id=owner_user_id if not is_admin else None, organization_id=organization_id) # Provider 健康是系统级信息,仅管理员可见;普通用户不返回 provider_health = [] if is_admin: providers = session.exec(select(ModelProvider)).all() provider_health = [{ "id": p.id, "name": p.name, "enabled": p.enabled, "backend": "keychain" if (p.api_key or "").startswith("keychain:") else "database", "models": len(p.models or []), } for p in providers] dead_query = select(TaskQueueItem).where(TaskQueueItem.status == "dead") dead_items = session.exec(dead_query).all() if not is_admin: # 队列项没有 user_id 字段:按 payload 里记录的任务归属过滤 own_campaign_ids = {int(c.id or 0) for c in campaigns} dead_items = [item for item in dead_items if int((item.payload or {}).get("campaign_id", 0) or 0) in own_campaign_ids] dead_list = [{"id": item.id, "task_key": item.task_key, "error": (item.error or "")[:160], "attempt": item.attempt, "updated_at": item.updated_at} for item in dead_items] # 告警隔离:管理员看系统告警,普通用户只看自己任务相关的通知 if is_admin: alerts = session.exec(select(Notification).where( Notification.type == "alert").order_by(Notification.id.desc()).limit(20)).all() else: own_campaign_ids = {int(c.id or 0) for c in campaigns} alerts = [n for n in session.exec(select(Notification).where( Notification.type == "alert").order_by(Notification.id.desc()).limit(100)).all() if int(n.ref_id or 0) in own_campaign_ids][:20] scope = {"owner_user_id": int(owner_user_id), "scope": "self"} if not is_admin else {"scope": "org"} return { **scope, "queue": queue, "campaigns": {"active": active, "failed": failed, "done": done, "total": len(campaigns)}, "cost_7d": {"amount_minor": amount_minor, "tokens": tokens}, "knowledge": {**({"hit_rate": kb["hit_rate"], "repeat_rate": kb["repeat_rate"], "reference_coverage": kb["reference_coverage"], "by_kb": kb["by_kb"][:5]} if is_admin else {"hit_rate": 0, "repeat_rate": 0, "reference_coverage": 0, "by_kb": []})}, "providers": provider_health, "dead_letter": dead_list, "backup": backup_mod.backup_status() if is_admin else {}, "recent_alerts": [{"id": n.id, "message": n.message, "created_at": n.created_at} for n in alerts], "generated_at": now(), } __all__ = ["raise_alert", "run_alert_sweep", "ops_dashboard", "ALERT_DEDUP_WINDOW_MINUTES", "DISK_MIN_FREE_GB", "POINTS_LOW_THRESHOLD"]