Files
server-26/drb-c2-core/app/internal/ai_health.py
T
Logan CusanoandClaude Opus 5.5 aff3f16d32 Admin Replay: re-run the pipeline over past calls in a sandbox (#170)
Correlation has only ever been measured through live AI windows: days of
wall time per change, and the 09-20→22 window was invalidated outright by
unfunded AI accounts (#169). Recordings are kept regardless of AI, so the
traffic to measure against already exists.

- internal/replay.py: runs a time range of real calls through the live
  pipeline code in original order, clock pinned per call, into
  replay_runs/{run_id}/calls|incidents. Modes: audio (re-transcribe),
  transcripts (re-extract), reuse (correlation only from a prior run's
  scenes). Simulates the idle-resolve and orphan-recorrelation sweeps on
  virtual time. No alerts, summaries, vocab, AI-health alerts or pending
  terms. One run at a time, <=5000 calls, <=7 days.
- firestore.py: ContextVar sandbox redirect for calls/incidents.
- clock.py: ContextVar-pinnable now(), used on the correlation path.
- feature_flags.py: ContextVar flag override so replay runs with live AI off.
- upload.py: scene loop extracted to _extract_and_correlate, shared by the
  live pipeline and replay so replay measures the code that runs live.
- resolved_via on every incident resolve, so a real clear can be told
  from the idle timeout — live and in replay.
- routers/replay.py + /admin Replay tab: estimate, start, compare runs,
  drill into incidents with audio.

Reviewed by drb-correlation-review; its leak and fidelity findings are
fixed and covered by tests. c2-core: 456 pass. Frontend typecheck not run
(no Node on the authoring box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:23:57 -04:00

201 lines
7.0 KiB
Python

"""
Shared AI-provider degradation registry.
On the night of 2026-08-18 three independent AI dependency failures (a
retired Gemini model ID, a depleted Gemini balance, an unpayable OpenAI
account) each surfaced only as a single ERROR log line -- and nobody reads
container logs continuously. This module is the fix: every AI call site
reports its outcome here instead of (or in addition to) just logging, so the
current state of every AI tier can be read back over HTTP (see
app/main.py's /health/ai) and pushed out to Discord on state changes.
Tiers are tracked independently and in memory only (module-level singleton,
no Firestore/DI -- consistent with the rest of this codebase). State is lost
on restart, which is fine: a fresh process should re-derive degradation from
the next few calls rather than resurrect a possibly-stale alert.
The load-bearing distinction, from the incident this module exists to
prevent: a PERMANENT condition (retired model, dead billing account, bad API
key) will never clear on its own and must alert on the very first
occurrence. A TRANSIENT condition (rate limit, network blip) clears by
itself constantly and must NOT page anyone for the first failure -- only if
it persists. classify() is the one place that tells the two apart from a
provider error message, because both this module's callers (llm_correlator.py,
transcription.py) need the exact same judgment call and must not each grow
their own slightly-different copy that drifts.
"""
import asyncio
from datetime import datetime, timezone
from typing import Optional
from app.internal.logger import logger
from app.config import settings
TIERS = ("transcription", "correlation_cheap", "correlation_smart", "extraction")
# Consecutive failures a TRANSIENT condition must reach before it alerts.
# Permanent conditions skip this entirely and alert on failure #1.
TRANSIENT_ALERT_THRESHOLD = 5
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _default_state() -> dict:
return {
"degraded": False,
"permanent": False,
"provider": None,
"model": None,
"problem": None,
"fix": None,
"first_seen": None,
"last_seen": None,
"consecutive_failures": 0,
"alerted": False,
}
_state: dict[str, dict] = {t: _default_state() for t in TIERS}
def classify(text: str) -> str:
"""
Classify a provider failure message body.
Returns "dead_model", "billing", or "transient".
A depleted balance and an ordinary rate limit both arrive as HTTP 429 --
the status code can't tell them apart, only the message body can. This
logic previously lived independently in llm_correlator.py and (in a
slightly different shape) transcription.py; it now lives here once, and
both call in rather than re-matching the text themselves.
"""
low = text.lower()
if "404" in text or "not found" in low or "no longer available" in low:
return "dead_model"
if (
"credits are depleted" in low
or "prepayment" in low
or "billing" in low
or "insufficient_quota" in low
or "credit" in low
or "exceeded your current quota" in low
):
return "billing"
return "transient"
async def report_degraded(
tier: str,
provider: str,
model: str,
problem: str,
fix: str,
permanent: bool = False,
) -> None:
"""
Record a failure for `tier`. Call this from a failure path, once per
failure (it does its own once-per-episode alert suppression -- do not
gate the call site on that yourself).
permanent=True (dead model, unpayable account, bad key) alerts on this
very call. permanent=False (rate limit, network blip) only alerts once
TRANSIENT_ALERT_THRESHOLD consecutive failures have been reported for
this tier, so an ordinary blip never pages anyone.
"""
from app.internal import firestore as fstore
if fstore.in_sandbox():
# A replay's rate limits are not a live outage, and must never page
# the AI-alert webhook or flip /health/ai (app/internal/replay.py).
return
if tier not in _state:
_state[tier] = _default_state()
entry = _state[tier]
now = _now()
if entry["consecutive_failures"] == 0:
entry["first_seen"] = now
entry["last_seen"] = now
entry["consecutive_failures"] += 1
entry["provider"] = provider
entry["model"] = model
entry["problem"] = problem
entry["fix"] = fix
entry["permanent"] = permanent
should_alert_now = permanent or entry["consecutive_failures"] >= TRANSIENT_ALERT_THRESHOLD
if should_alert_now and not entry["degraded"]:
entry["degraded"] = True
if should_alert_now and not entry["alerted"]:
entry["alerted"] = True
await _post_webhook(
f"**AI tier degraded: {tier}**\n"
f"Provider: {provider} ({model})\n"
f"Problem: {problem}\n"
f"Fix: {fix}\n"
f"Kind: {'permanent' if permanent else 'transient, persisted ' + str(entry['consecutive_failures']) + ' calls'}"
)
async def report_healthy(tier: str) -> None:
"""
Record a successful call for `tier`. Call this on every success, not
just after a failure -- it is what lets a degraded tier recover on its
own instead of staying red forever after one transient blip.
"""
from app.internal import firestore as fstore
if fstore.in_sandbox():
return # nor may a replay's success "recover" a real live outage
if tier not in _state:
_state[tier] = _default_state()
entry = _state[tier]
was_alerted = entry["alerted"]
was_degraded = entry["degraded"]
provider, model = entry["provider"], entry["model"]
_state[tier] = _default_state()
# Keep the last-known provider/model around for the recovery message
# and for a quick glance at snapshot() even when healthy.
_state[tier]["provider"] = provider
_state[tier]["model"] = model
if was_alerted:
await _post_webhook(f"**AI tier recovered: {tier}**\nProvider: {provider} ({model})")
elif was_degraded:
# Reached "degraded" internally but never crossed the alert
# threshold before recovering -- nothing was ever posted, so
# nothing needs un-posting. Nothing to do.
pass
def snapshot() -> dict:
"""Current state of every tier, for /health/ai."""
return {tier: dict(entry) for tier, entry in _state.items()}
async def _post_webhook(content: str) -> None:
"""
POST a message to the AI-alert Discord webhook, if one is configured.
Same httpx pattern as app/internal/alerter.py's _post_webhook: short
timeout, never raises. Self-hosted deployments that don't set
ai_alert_webhook_url just skip this silently.
"""
url = settings.ai_alert_webhook_url
if not url:
return
try:
import httpx
async with httpx.AsyncClient(timeout=5.0) as client:
await client.post(url, json={"content": content})
except Exception as e:
logger.warning(f"ai_health: Discord webhook POST failed: {e}")