Imports 67 files from VPS (/opt/teleo-eval/) into repo as the single source of truth. Previously only 8 of 67 files existed in repo — the rest were deployed directly to VPS via SCP, causing massive drift. Includes: - pipeline/lib/: 33 Python modules (daemon core, extraction, evaluation, merge, cascade, cross-domain, costs, attribution, etc.) - pipeline/: main daemon (teleo-pipeline.py), reweave.py, batch-extract-50.sh - diagnostics/: 19 files (4-page dashboard, alerting, daily digest, review queue, tier1 metrics) - agent-state/: bootstrap, lib-state, cascade inbox processor, schema - systemd/: service unit files for reference - deploy.sh: rsync-based deploy with --dry-run, syntax checks, dirty-tree gate - research-session.sh: updated with Step 8.5 digest + cascade inbox processing No new code written — all files are exact copies from VPS as of 2026-04-06. From this point forward: edit in repo, commit, then deploy.sh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2299 lines
96 KiB
Python
2299 lines
96 KiB
Python
"""Argus — Diagnostics dashboard + search API for the Teleo pipeline.
|
||
|
||
Separate aiohttp service (port 8081) that reads pipeline.db read-only.
|
||
Provides Chart.js operational dashboard, quality vital signs, contributor analytics,
|
||
semantic search via Qdrant, and claim usage logging.
|
||
|
||
Owner: Argus <69AF7290-758F-464B-B472-04AFCA4AB340>
|
||
Data source: Epimetheus's pipeline.db (read-only SQLite), Qdrant vector DB
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import sqlite3
|
||
import statistics
|
||
import sys
|
||
import urllib.request
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
# Add pipeline lib to path so we can import shared modules
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "pipeline"))
|
||
|
||
from aiohttp import web
|
||
from review_queue_routes import register_review_queue_routes
|
||
from daily_digest_routes import register_daily_digest_routes
|
||
from response_audit_routes import register_response_audit_routes, RESPONSE_AUDIT_PUBLIC_PATHS
|
||
from lib.search import search as kb_search, embed_query, search_qdrant
|
||
|
||
logger = logging.getLogger("argus")
|
||
|
||
# --- Config ---
|
||
DB_PATH = Path(os.environ.get("PIPELINE_DB", "/opt/teleo-eval/pipeline/pipeline.db"))
|
||
PORT = int(os.environ.get("ARGUS_PORT", "8081"))
|
||
REPO_DIR = Path(os.environ.get("REPO_DIR", "/opt/teleo-eval/workspaces/main"))
|
||
CLAIM_INDEX_URL = os.environ.get("CLAIM_INDEX_URL", "http://localhost:8080/claim-index")
|
||
|
||
# Search config — moved to lib/search.py (shared with Telegram bot + agents)
|
||
|
||
# Auth config
|
||
API_KEY_FILE = Path(os.environ.get("ARGUS_API_KEY_FILE", "/opt/teleo-eval/secrets/argus-api-key"))
|
||
|
||
# Endpoints that skip auth (dashboard is public for now, can lock later)
|
||
_PUBLIC_PATHS = frozenset({"/", "/prs", "/ops", "/health", "/agents", "/epistemic", "/legacy", "/audit", "/api/metrics", "/api/snapshots", "/api/vital-signs",
|
||
"/api/contributors", "/api/domains", "/api/audit", "/api/yield", "/api/cost-per-claim", "/api/fix-rates", "/api/compute-profile", "/api/review-queue", "/api/daily-digest"})
|
||
|
||
|
||
def _get_db() -> sqlite3.Connection:
|
||
"""Open read-only connection to pipeline.db."""
|
||
# URI mode for true OS-level read-only (Rhea: belt and suspenders)
|
||
conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=30)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("PRAGMA busy_timeout=10000")
|
||
return conn
|
||
|
||
|
||
def _conn(request) -> sqlite3.Connection:
|
||
"""Get DB connection with health check. Reopens if stale."""
|
||
conn = request.app["db"]
|
||
try:
|
||
conn.execute("SELECT 1")
|
||
except sqlite3.Error:
|
||
conn = _get_db()
|
||
request.app["db"] = conn
|
||
return conn
|
||
|
||
|
||
# ─── Data queries ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _current_metrics(conn) -> dict:
|
||
"""Compute current operational metrics from live DB state."""
|
||
# Throughput (merged in last hour)
|
||
merged_1h = conn.execute(
|
||
"SELECT COUNT(*) as n FROM prs WHERE merged_at > datetime('now', '-1 hour')"
|
||
).fetchone()["n"]
|
||
|
||
# PR status counts
|
||
statuses = conn.execute("SELECT status, COUNT(*) as n FROM prs GROUP BY status").fetchall()
|
||
status_map = {r["status"]: r["n"] for r in statuses}
|
||
|
||
# Approval rate (24h) from audit_log
|
||
evaluated = conn.execute(
|
||
"SELECT COUNT(*) as n FROM audit_log WHERE stage='evaluate' "
|
||
"AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected') "
|
||
"AND timestamp > datetime('now','-24 hours')"
|
||
).fetchone()["n"]
|
||
approved = conn.execute(
|
||
"SELECT COUNT(*) as n FROM audit_log WHERE stage='evaluate' "
|
||
"AND event='approved' AND timestamp > datetime('now','-24 hours')"
|
||
).fetchone()["n"]
|
||
approval_rate = round(approved / evaluated, 3) if evaluated else 0
|
||
|
||
# Rejection reasons (24h) — count events AND unique PRs
|
||
reasons = conn.execute(
|
||
"""SELECT value as tag, COUNT(*) as cnt,
|
||
COUNT(DISTINCT json_extract(detail, '$.pr')) as unique_prs
|
||
FROM audit_log, json_each(json_extract(detail, '$.issues'))
|
||
WHERE stage='evaluate'
|
||
AND event IN ('changes_requested','domain_rejected','tier05_rejected')
|
||
AND timestamp > datetime('now','-24 hours')
|
||
GROUP BY tag ORDER BY cnt DESC LIMIT 10"""
|
||
).fetchall()
|
||
|
||
# Fix cycle
|
||
fix_stats = conn.execute(
|
||
"SELECT COUNT(*) as attempted, "
|
||
"SUM(CASE WHEN status='merged' THEN 1 ELSE 0 END) as succeeded "
|
||
"FROM prs WHERE fix_attempts > 0"
|
||
).fetchone()
|
||
fix_attempted = fix_stats["attempted"] or 0
|
||
fix_succeeded = fix_stats["succeeded"] or 0
|
||
fix_rate = round(fix_succeeded / fix_attempted, 3) if fix_attempted else 0
|
||
|
||
# Median time to merge (24h)
|
||
merge_times = conn.execute(
|
||
"SELECT (julianday(merged_at) - julianday(created_at)) * 24 * 60 as minutes "
|
||
"FROM prs WHERE merged_at IS NOT NULL AND merged_at > datetime('now', '-24 hours')"
|
||
).fetchall()
|
||
durations = [r["minutes"] for r in merge_times if r["minutes"] and r["minutes"] > 0]
|
||
median_ttm = round(statistics.median(durations), 1) if durations else None
|
||
|
||
# Source pipeline
|
||
source_statuses = conn.execute(
|
||
"SELECT status, COUNT(*) as n FROM sources GROUP BY status"
|
||
).fetchall()
|
||
source_map = {r["status"]: r["n"] for r in source_statuses}
|
||
|
||
# Domain breakdown
|
||
domain_counts = conn.execute(
|
||
"SELECT domain, status, COUNT(*) as n FROM prs GROUP BY domain, status"
|
||
).fetchall()
|
||
domains = {}
|
||
for r in domain_counts:
|
||
d = r["domain"] or "unknown"
|
||
if d not in domains:
|
||
domains[d] = {}
|
||
domains[d][r["status"]] = r["n"]
|
||
|
||
# Breakers
|
||
breakers = conn.execute(
|
||
"SELECT name, state, failures, last_success_at FROM circuit_breakers"
|
||
).fetchall()
|
||
breaker_map = {}
|
||
for b in breakers:
|
||
info = {"state": b["state"], "failures": b["failures"]}
|
||
if b["last_success_at"]:
|
||
last = datetime.fromisoformat(b["last_success_at"])
|
||
if last.tzinfo is None:
|
||
last = last.replace(tzinfo=timezone.utc)
|
||
age_s = (datetime.now(timezone.utc) - last).total_seconds()
|
||
info["age_s"] = round(age_s)
|
||
breaker_map[b["name"]] = info
|
||
|
||
return {
|
||
"throughput_1h": merged_1h,
|
||
"approval_rate": approval_rate,
|
||
"evaluated_24h": evaluated,
|
||
"approved_24h": approved,
|
||
"status_map": status_map,
|
||
"source_map": source_map,
|
||
"rejection_reasons": [{"tag": r["tag"], "count": r["cnt"], "unique_prs": r["unique_prs"]} for r in reasons],
|
||
"fix_rate": fix_rate,
|
||
"fix_attempted": fix_attempted,
|
||
"fix_succeeded": fix_succeeded,
|
||
"median_ttm_minutes": median_ttm,
|
||
"domains": domains,
|
||
"breakers": breaker_map,
|
||
}
|
||
|
||
|
||
def _snapshot_history(conn, days: int = 7) -> list[dict]:
|
||
"""Get metrics_snapshots time series."""
|
||
rows = conn.execute(
|
||
"SELECT * FROM metrics_snapshots WHERE ts > datetime('now', ? || ' days') ORDER BY ts ASC",
|
||
(f"-{days}",),
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
def _version_changes(conn, days: int = 30) -> list[dict]:
|
||
"""Get prompt/pipeline version change events for chart annotations."""
|
||
rows = conn.execute(
|
||
"SELECT ts, prompt_version, pipeline_version FROM metrics_snapshots "
|
||
"WHERE ts > datetime('now', ? || ' days') ORDER BY ts ASC",
|
||
(f"-{days}",),
|
||
).fetchall()
|
||
changes = []
|
||
prev_prompt = prev_pipeline = None
|
||
for row in rows:
|
||
if row["prompt_version"] != prev_prompt and prev_prompt is not None:
|
||
changes.append({"ts": row["ts"], "type": "prompt", "from": prev_prompt, "to": row["prompt_version"]})
|
||
if row["pipeline_version"] != prev_pipeline and prev_pipeline is not None:
|
||
changes.append({"ts": row["ts"], "type": "pipeline", "from": prev_pipeline, "to": row["pipeline_version"]})
|
||
prev_prompt = row["prompt_version"]
|
||
prev_pipeline = row["pipeline_version"]
|
||
return changes
|
||
|
||
|
||
def _has_column(conn, table: str, column: str) -> bool:
|
||
"""Check if a column exists in a table (graceful schema migration support)."""
|
||
cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||
return any(c["name"] == column for c in cols)
|
||
|
||
|
||
def _contributor_leaderboard(conn, limit: int = 20, view: str = "principal") -> list[dict]:
|
||
"""Top contributors by CI score.
|
||
|
||
view="agent" — one row per contributor handle (original behavior)
|
||
view="principal" — rolls up agent contributions to their principal (human)
|
||
"""
|
||
has_principal = _has_column(conn, "contributors", "principal")
|
||
|
||
rows = conn.execute(
|
||
"SELECT handle, tier, claims_merged, sourcer_count, extractor_count, "
|
||
"challenger_count, synthesizer_count, reviewer_count, domains, last_contribution"
|
||
+ (", principal" if has_principal else "") +
|
||
" FROM contributors ORDER BY claims_merged DESC",
|
||
).fetchall()
|
||
|
||
# Weights reward quality over volume (Cory-approved)
|
||
weights = {"sourcer": 0.15, "extractor": 0.05, "challenger": 0.35, "synthesizer": 0.25, "reviewer": 0.20}
|
||
role_keys = list(weights.keys())
|
||
|
||
if view == "principal" and has_principal:
|
||
# Aggregate by principal — agents with a principal roll up to the human
|
||
buckets: dict[str, dict] = {}
|
||
for r in rows:
|
||
principal = r["principal"]
|
||
key = principal if principal else r["handle"]
|
||
if key not in buckets:
|
||
buckets[key] = {
|
||
"handle": key,
|
||
"tier": r["tier"],
|
||
"claims_merged": 0,
|
||
"domains": set(),
|
||
"last_contribution": None,
|
||
"agents": [],
|
||
**{f"{role}_count": 0 for role in role_keys},
|
||
}
|
||
b = buckets[key]
|
||
b["claims_merged"] += r["claims_merged"] or 0
|
||
for role in role_keys:
|
||
b[f"{role}_count"] += r[f"{role}_count"] or 0
|
||
if r["domains"]:
|
||
b["domains"].update(json.loads(r["domains"]))
|
||
if r["last_contribution"]:
|
||
if not b["last_contribution"] or r["last_contribution"] > b["last_contribution"]:
|
||
b["last_contribution"] = r["last_contribution"]
|
||
# Upgrade tier (veteran > contributor > new)
|
||
tier_rank = {"veteran": 2, "contributor": 1, "new": 0}
|
||
if tier_rank.get(r["tier"], 0) > tier_rank.get(b["tier"], 0):
|
||
b["tier"] = r["tier"]
|
||
if principal:
|
||
b["agents"].append(r["handle"])
|
||
|
||
result = []
|
||
for b in buckets.values():
|
||
ci = sum(b[f"{role}_count"] * w for role, w in weights.items())
|
||
result.append({
|
||
"handle": b["handle"],
|
||
"tier": b["tier"],
|
||
"claims_merged": b["claims_merged"],
|
||
"ci": round(ci, 2),
|
||
"domains": sorted(b["domains"])[:5],
|
||
"last_contribution": b["last_contribution"],
|
||
"agents": b["agents"],
|
||
})
|
||
else:
|
||
# By-agent view (original behavior)
|
||
result = []
|
||
for r in rows:
|
||
ci = sum((r[f"{role}_count"] or 0) * w for role, w in weights.items())
|
||
entry = {
|
||
"handle": r["handle"],
|
||
"tier": r["tier"],
|
||
"claims_merged": r["claims_merged"] or 0,
|
||
"ci": round(ci, 2),
|
||
"domains": json.loads(r["domains"]) if r["domains"] else [],
|
||
"last_contribution": r["last_contribution"],
|
||
}
|
||
if has_principal:
|
||
entry["principal"] = r["principal"]
|
||
result.append(entry)
|
||
|
||
result = sorted(result, key=lambda x: x["ci"], reverse=True)
|
||
return result[:limit]
|
||
|
||
|
||
# ─── Vital signs (Vida's five) ───────────────────────────────────────────────
|
||
|
||
|
||
def _fetch_claim_index() -> dict | None:
|
||
"""Fetch claim-index from Epimetheus. Returns parsed JSON or None on failure."""
|
||
try:
|
||
with urllib.request.urlopen(CLAIM_INDEX_URL, timeout=5) as resp:
|
||
return json.loads(resp.read())
|
||
except Exception as e:
|
||
logger.warning("Failed to fetch claim-index from %s: %s", CLAIM_INDEX_URL, e)
|
||
return None
|
||
|
||
|
||
def _compute_vital_signs(conn) -> dict:
|
||
"""Compute Vida's five vital signs from DB state + claim-index."""
|
||
|
||
# 1. Review throughput — backlog and latency
|
||
# Query Forgejo directly for authoritative PR counts (DB misses agent-created PRs)
|
||
forgejo_open = 0
|
||
forgejo_unmergeable = 0
|
||
try:
|
||
import requests as _req
|
||
_token = Path("/opt/teleo-eval/secrets/forgejo-token").read_text().strip() if Path("/opt/teleo-eval/secrets/forgejo-token").exists() else ""
|
||
_resp = _req.get(
|
||
"http://localhost:3000/api/v1/repos/teleo/teleo-codex/pulls?state=open&limit=50",
|
||
headers={"Authorization": f"token {_token}"} if _token else {},
|
||
timeout=10,
|
||
)
|
||
if _resp.status_code == 200:
|
||
_prs = _resp.json()
|
||
forgejo_open = len(_prs)
|
||
forgejo_unmergeable = sum(1 for p in _prs if not p.get("mergeable", True))
|
||
except Exception:
|
||
# Fallback to DB counts if Forgejo unreachable
|
||
forgejo_open = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='open'").fetchone()["n"]
|
||
|
||
open_prs = forgejo_open
|
||
conflict_prs = forgejo_unmergeable
|
||
conflict_permanent_prs = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='conflict_permanent'").fetchone()["n"]
|
||
approved_prs = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='approved'").fetchone()["n"]
|
||
reviewing_prs = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='reviewing'").fetchone()["n"]
|
||
backlog = open_prs
|
||
|
||
oldest_open = conn.execute(
|
||
"SELECT MIN(created_at) as oldest FROM prs WHERE status='open'"
|
||
).fetchone()
|
||
review_latency_h = None
|
||
if oldest_open and oldest_open["oldest"]:
|
||
oldest = datetime.fromisoformat(oldest_open["oldest"])
|
||
if oldest.tzinfo is None:
|
||
oldest = oldest.replace(tzinfo=timezone.utc)
|
||
review_latency_h = round((datetime.now(timezone.utc) - oldest).total_seconds() / 3600, 1)
|
||
|
||
# 2-5. Claim-index vital signs
|
||
ci = _fetch_claim_index()
|
||
orphan_ratio = None
|
||
linkage_density = None
|
||
confidence_dist = {}
|
||
evidence_freshness = None
|
||
claim_index_status = "unavailable"
|
||
|
||
if ci and ci.get("claims"):
|
||
claims = ci["claims"]
|
||
total = len(claims)
|
||
claim_index_status = "live"
|
||
|
||
# 2. Orphan ratio (Vida: <15% healthy)
|
||
orphan_count = ci.get("orphan_count", sum(1 for c in claims if c.get("incoming_count", 0) == 0))
|
||
orphan_ratio = round(orphan_count / total, 3) if total else 0
|
||
|
||
# 3. Linkage density — avg outgoing links per claim + cross-domain ratio
|
||
total_outgoing = sum(c.get("outgoing_count", 0) for c in claims)
|
||
avg_links = round(total_outgoing / total, 2) if total else 0
|
||
cross_domain = ci.get("cross_domain_links", 0)
|
||
linkage_density = {
|
||
"avg_outgoing_links": avg_links,
|
||
"cross_domain_links": cross_domain,
|
||
"cross_domain_ratio": round(cross_domain / total_outgoing, 3) if total_outgoing else 0,
|
||
}
|
||
|
||
# 4. Confidence distribution + calibration
|
||
for c in claims:
|
||
conf = c.get("confidence", "unknown")
|
||
confidence_dist[conf] = confidence_dist.get(conf, 0) + 1
|
||
# Normalize to percentages
|
||
confidence_pct = {k: round(v / total * 100, 1) for k, v in sorted(confidence_dist.items())}
|
||
|
||
# 5. Evidence freshness — avg age of claims in days
|
||
today = datetime.now(timezone.utc).date()
|
||
ages = []
|
||
for c in claims:
|
||
try:
|
||
if c.get("created"):
|
||
created = datetime.strptime(c["created"], "%Y-%m-%d").date()
|
||
ages.append((today - created).days)
|
||
except (ValueError, KeyError, TypeError):
|
||
pass
|
||
avg_age_days = round(statistics.mean(ages)) if ages else None
|
||
median_age_days = round(statistics.median(ages)) if ages else None
|
||
fresh_30d = sum(1 for a in ages if a <= 30)
|
||
evidence_freshness = {
|
||
"avg_age_days": avg_age_days,
|
||
"median_age_days": median_age_days,
|
||
"fresh_30d_count": fresh_30d,
|
||
"fresh_30d_pct": round(fresh_30d / total * 100, 1) if total else 0,
|
||
}
|
||
|
||
# Domain activity (last 7 days) — stagnation detection
|
||
domain_activity = conn.execute(
|
||
"SELECT domain, COUNT(*) as n, MAX(last_attempt) as latest "
|
||
"FROM prs WHERE last_attempt > datetime('now', '-7 days') GROUP BY domain"
|
||
).fetchall()
|
||
stagnant_domains = []
|
||
active_domains = []
|
||
for r in domain_activity:
|
||
active_domains.append({"domain": r["domain"], "prs_7d": r["n"], "latest": r["latest"]})
|
||
all_domains = conn.execute("SELECT DISTINCT domain FROM prs WHERE domain IS NOT NULL").fetchall()
|
||
active_names = {r["domain"] for r in domain_activity}
|
||
for r in all_domains:
|
||
if r["domain"] not in active_names:
|
||
stagnant_domains.append(r["domain"])
|
||
|
||
# Pipeline funnel
|
||
total_sources = conn.execute("SELECT COUNT(*) as n FROM sources").fetchone()["n"]
|
||
queued_sources = conn.execute(
|
||
"SELECT COUNT(*) as n FROM sources WHERE status='unprocessed'"
|
||
).fetchone()["n"]
|
||
extracted_sources = conn.execute(
|
||
"SELECT COUNT(*) as n FROM sources WHERE status='extracted'"
|
||
).fetchone()["n"]
|
||
merged_prs = conn.execute("SELECT COUNT(*) as n FROM prs WHERE status='merged'").fetchone()["n"]
|
||
total_prs = conn.execute("SELECT COUNT(*) as n FROM prs").fetchone()["n"]
|
||
funnel = {
|
||
"sources_total": total_sources,
|
||
"sources_queued": queued_sources,
|
||
"sources_extracted": extracted_sources,
|
||
"prs_total": total_prs,
|
||
"prs_merged": merged_prs,
|
||
"conversion_rate": round(merged_prs / total_prs, 3) if total_prs else 0,
|
||
}
|
||
|
||
# Queue staleness — sources unprocessed for >7 days
|
||
stale_buckets = conn.execute("""
|
||
SELECT
|
||
CASE
|
||
WHEN created_at < datetime('now', '-30 days') THEN '30d+'
|
||
WHEN created_at < datetime('now', '-14 days') THEN '14-30d'
|
||
WHEN created_at < datetime('now', '-7 days') THEN '7-14d'
|
||
ELSE 'fresh'
|
||
END as age_bucket,
|
||
COUNT(*) as cnt
|
||
FROM sources
|
||
WHERE status = 'unprocessed'
|
||
GROUP BY age_bucket
|
||
""").fetchall()
|
||
stale_map = {r["age_bucket"]: r["cnt"] for r in stale_buckets}
|
||
stale_total = sum(v for k, v in stale_map.items() if k != "fresh")
|
||
|
||
oldest_unprocessed = conn.execute(
|
||
"SELECT MIN(created_at) as oldest FROM sources WHERE status='unprocessed'"
|
||
).fetchone()
|
||
oldest_age_days = None
|
||
if oldest_unprocessed and oldest_unprocessed["oldest"]:
|
||
oldest_dt = datetime.fromisoformat(oldest_unprocessed["oldest"])
|
||
if oldest_dt.tzinfo is None:
|
||
oldest_dt = oldest_dt.replace(tzinfo=timezone.utc)
|
||
oldest_age_days = round((datetime.now(timezone.utc) - oldest_dt).total_seconds() / 86400, 1)
|
||
|
||
queue_staleness = {
|
||
"stale_count": stale_total,
|
||
"buckets": stale_map,
|
||
"oldest_age_days": oldest_age_days,
|
||
"status": "healthy" if stale_total == 0 else ("warning" if stale_total <= 10 else "critical"),
|
||
}
|
||
|
||
return {
|
||
"claim_index_status": claim_index_status,
|
||
"review_throughput": {
|
||
"backlog": backlog,
|
||
"open_prs": open_prs,
|
||
"approved_waiting": approved_prs,
|
||
"conflict_prs": conflict_prs,
|
||
"conflict_permanent_prs": conflict_permanent_prs,
|
||
"reviewing_prs": reviewing_prs,
|
||
"oldest_open_hours": review_latency_h,
|
||
"status": "healthy" if backlog <= 3 else ("warning" if backlog <= 10 else "critical"),
|
||
},
|
||
"orphan_ratio": {
|
||
"ratio": orphan_ratio,
|
||
"count": ci.get("orphan_count") if ci else None,
|
||
"total": ci.get("total_claims") if ci else None,
|
||
"status": "healthy" if orphan_ratio and orphan_ratio < 0.15 else ("warning" if orphan_ratio and orphan_ratio < 0.30 else "critical") if orphan_ratio is not None else "unavailable",
|
||
},
|
||
"linkage_density": linkage_density,
|
||
"confidence_distribution": confidence_dist,
|
||
"evidence_freshness": evidence_freshness,
|
||
"domain_activity": {
|
||
"active": active_domains,
|
||
"stagnant": stagnant_domains,
|
||
"status": "healthy" if not stagnant_domains else "warning",
|
||
},
|
||
"funnel": funnel,
|
||
"queue_staleness": queue_staleness,
|
||
}
|
||
|
||
|
||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _load_secret(path: Path) -> str | None:
|
||
"""Load a secret from a file. Returns None if missing."""
|
||
try:
|
||
return path.read_text().strip()
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
@web.middleware
|
||
async def auth_middleware(request, handler):
|
||
"""API key check. Public paths skip auth. Protected paths require X-Api-Key header."""
|
||
if request.path in _PUBLIC_PATHS or request.path in RESPONSE_AUDIT_PUBLIC_PATHS or request.path.startswith("/api/response-audit/"):
|
||
return await handler(request)
|
||
expected = request.app.get("api_key")
|
||
if not expected:
|
||
# No key configured — all endpoints open (development mode)
|
||
return await handler(request)
|
||
provided = request.headers.get("X-Api-Key", "")
|
||
if provided != expected:
|
||
return web.json_response({"error": "unauthorized"}, status=401)
|
||
return await handler(request)
|
||
|
||
|
||
# ─── Embedding + Search ──────────────────────────────────────────────────────
|
||
# Moved to lib/search.py — imported at top of file as kb_search, embed_query, search_qdrant
|
||
|
||
|
||
# ─── Usage logging ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def _get_write_db() -> sqlite3.Connection | None:
|
||
"""Open read-write connection for usage logging only.
|
||
|
||
Separate from the main read-only connection. Returns None if DB unavailable.
|
||
"""
|
||
try:
|
||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("PRAGMA busy_timeout=10000")
|
||
# Ensure claim_usage table exists (Epimetheus creates it, but be safe)
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS claim_usage (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
claim_path TEXT NOT NULL,
|
||
agent TEXT,
|
||
context TEXT,
|
||
ts TEXT DEFAULT (datetime('now'))
|
||
)
|
||
""")
|
||
conn.commit()
|
||
return conn
|
||
except Exception as e:
|
||
logger.warning("Failed to open write DB for usage logging: %s", e)
|
||
return None
|
||
|
||
|
||
# ─── Route handlers ─────────────────────────────────────────────────────────
|
||
|
||
|
||
async def handle_dashboard(request):
|
||
"""GET / — main Chart.js operational dashboard."""
|
||
try:
|
||
conn = _conn(request)
|
||
metrics = _current_metrics(conn)
|
||
snapshots = _snapshot_history(conn, days=7)
|
||
changes = _version_changes(conn, days=30)
|
||
vital_signs = _compute_vital_signs(conn)
|
||
contributors_principal = _contributor_leaderboard(conn, limit=10, view="principal")
|
||
contributors_agent = _contributor_leaderboard(conn, limit=10, view="agent")
|
||
domain_breakdown = _domain_breakdown(conn)
|
||
except sqlite3.Error as e:
|
||
return web.Response(
|
||
text=_render_error(f"Pipeline database unavailable: {e}"),
|
||
content_type="text/html",
|
||
status=503,
|
||
)
|
||
now = datetime.now(timezone.utc)
|
||
html = _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, domain_breakdown, now)
|
||
return web.Response(text=html, content_type="text/html")
|
||
|
||
|
||
async def handle_api_metrics(request):
|
||
"""GET /api/metrics — JSON operational metrics."""
|
||
conn = _conn(request)
|
||
return web.json_response(_current_metrics(conn))
|
||
|
||
|
||
async def handle_api_snapshots(request):
|
||
"""GET /api/snapshots?days=7 — time-series data for charts."""
|
||
conn = _conn(request)
|
||
days = int(request.query.get("days", "7"))
|
||
snapshots = _snapshot_history(conn, days)
|
||
changes = _version_changes(conn, days)
|
||
return web.json_response({"snapshots": snapshots, "version_changes": changes, "days": days})
|
||
|
||
|
||
async def handle_api_vital_signs(request):
|
||
"""GET /api/vital-signs — Vida's five vital signs."""
|
||
conn = _conn(request)
|
||
return web.json_response(_compute_vital_signs(conn))
|
||
|
||
|
||
async def handle_api_contributors(request):
|
||
"""GET /api/contributors — contributor leaderboard.
|
||
|
||
Query params:
|
||
limit: max entries (default 50)
|
||
view: "principal" (default, rolls up agents) or "agent" (one row per handle)
|
||
"""
|
||
conn = _conn(request)
|
||
limit = int(request.query.get("limit", "50"))
|
||
view = request.query.get("view", "principal")
|
||
if view not in ("principal", "agent"):
|
||
view = "principal"
|
||
contributors = _contributor_leaderboard(conn, limit, view=view)
|
||
return web.json_response({"contributors": contributors, "view": view})
|
||
|
||
|
||
def _domain_breakdown(conn) -> dict:
|
||
"""Per-domain contribution breakdown: claims, contributors, sources, decisions."""
|
||
# Claims per domain from merged knowledge PRs
|
||
domain_stats = {}
|
||
for r in conn.execute("""
|
||
SELECT domain, count(*) as prs,
|
||
SUM(CASE WHEN commit_type='knowledge' THEN 1 ELSE 0 END) as knowledge_prs
|
||
FROM prs WHERE status='merged' AND domain IS NOT NULL
|
||
GROUP BY domain ORDER BY prs DESC
|
||
""").fetchall():
|
||
domain_stats[r["domain"]] = {
|
||
"total_prs": r["prs"],
|
||
"knowledge_prs": r["knowledge_prs"] or 0,
|
||
"contributors": [],
|
||
}
|
||
|
||
# Top contributors per domain (from PR agent field + principal roll-up)
|
||
has_principal = _has_column(conn, "contributors", "principal")
|
||
for r in conn.execute("""
|
||
SELECT p.domain,
|
||
COALESCE(c.principal, p.agent, 'unknown') as contributor,
|
||
count(*) as cnt
|
||
FROM prs p
|
||
LEFT JOIN contributors c ON LOWER(p.agent) = c.handle
|
||
WHERE p.status='merged' AND p.commit_type='knowledge' AND p.domain IS NOT NULL
|
||
GROUP BY p.domain, contributor
|
||
ORDER BY p.domain, cnt DESC
|
||
""").fetchall():
|
||
domain = r["domain"]
|
||
if domain in domain_stats:
|
||
domain_stats[domain]["contributors"].append({
|
||
"handle": r["contributor"],
|
||
"claims": r["cnt"],
|
||
})
|
||
|
||
return domain_stats
|
||
|
||
|
||
async def handle_api_domains(request):
|
||
"""GET /api/domains — per-domain contribution breakdown.
|
||
|
||
Returns claims, contributors, and knowledge PR counts per domain.
|
||
"""
|
||
conn = _conn(request)
|
||
breakdown = _domain_breakdown(conn)
|
||
return web.json_response({"domains": breakdown})
|
||
|
||
|
||
async def handle_api_search(request):
|
||
"""GET /api/search — semantic search over claims via Qdrant + graph expansion.
|
||
|
||
Query params:
|
||
q: search query (required)
|
||
domain: filter by domain (optional)
|
||
confidence: filter by confidence level (optional)
|
||
limit: max results, default 10 (optional)
|
||
exclude: comma-separated claim paths to exclude (optional)
|
||
expand: enable graph expansion, default true (optional)
|
||
"""
|
||
query = request.query.get("q", "").strip()
|
||
if not query:
|
||
return web.json_response({"error": "q parameter required"}, status=400)
|
||
|
||
domain = request.query.get("domain")
|
||
confidence = request.query.get("confidence")
|
||
limit = min(int(request.query.get("limit", "10")), 50)
|
||
exclude_raw = request.query.get("exclude", "")
|
||
exclude = [p.strip() for p in exclude_raw.split(",") if p.strip()] if exclude_raw else None
|
||
expand = request.query.get("expand", "true").lower() != "false"
|
||
|
||
# Use shared search library (Layer 1 + Layer 2)
|
||
result = kb_search(query, expand=expand,
|
||
domain=domain, confidence=confidence, exclude=exclude)
|
||
|
||
if "error" in result:
|
||
error = result["error"]
|
||
if error == "embedding_failed":
|
||
return web.json_response({"error": "embedding failed"}, status=502)
|
||
return web.json_response({"error": error}, status=500)
|
||
|
||
return web.json_response(result)
|
||
|
||
|
||
async def handle_api_audit(request):
|
||
"""GET /api/audit — query response_audit table for agent response diagnostics.
|
||
|
||
Query params:
|
||
agent: filter by agent name (optional)
|
||
query: search in query text (optional)
|
||
limit: max results, default 50, max 200 (optional)
|
||
offset: pagination offset (optional)
|
||
days: how many days back, default 7 (optional)
|
||
"""
|
||
conn = _conn(request)
|
||
|
||
# Check if response_audit table exists
|
||
table_check = conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' AND name='response_audit'"
|
||
).fetchone()
|
||
if not table_check:
|
||
return web.json_response({"error": "response_audit table not found"}, status=404)
|
||
|
||
agent = request.query.get("agent")
|
||
status_filter = request.query.get("status", "").strip()
|
||
query_filter = request.query.get("query", "").strip()
|
||
limit = min(int(request.query.get("limit", "50")), 200)
|
||
offset = int(request.query.get("offset", "0"))
|
||
days = int(request.query.get("days", "7"))
|
||
|
||
where_clauses = ["timestamp > datetime('now', ?||' days')"]
|
||
params: list = [f"-{days}"]
|
||
|
||
if agent:
|
||
where_clauses.append("agent = ?")
|
||
params.append(agent)
|
||
if status_filter:
|
||
where_clauses.append("retrieval_status LIKE ?")
|
||
params.append(f"{status_filter}%")
|
||
if query_filter:
|
||
where_clauses.append("query LIKE ?")
|
||
params.append(f"%{query_filter}%")
|
||
|
||
where_sql = " AND ".join(where_clauses)
|
||
|
||
rows = conn.execute(
|
||
f"""SELECT id, timestamp, agent, chat_id, user, model, query,
|
||
conversation_window, entities_matched, claims_matched,
|
||
retrieval_layers_hit, retrieval_gap, research_context,
|
||
tool_calls, display_response, confidence_score, response_time_ms,
|
||
retrieval_status
|
||
FROM response_audit
|
||
WHERE {where_sql}
|
||
ORDER BY timestamp DESC
|
||
LIMIT ? OFFSET ?""",
|
||
params + [limit, offset],
|
||
).fetchall()
|
||
|
||
total = conn.execute(
|
||
f"SELECT COUNT(*) as n FROM response_audit WHERE {where_sql}",
|
||
params,
|
||
).fetchone()["n"]
|
||
|
||
results = []
|
||
for r in rows:
|
||
row_dict = dict(r)
|
||
# Parse JSON fields for the response
|
||
for json_field in ("claims_matched", "entities_matched", "retrieval_layers_hit",
|
||
"tool_calls", "conversation_window"):
|
||
if row_dict.get(json_field):
|
||
try:
|
||
row_dict[json_field] = json.loads(row_dict[json_field])
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
results.append(row_dict)
|
||
|
||
return web.json_response({"total": total, "results": results})
|
||
|
||
|
||
async def handle_audit_page(request):
|
||
"""GET /audit — HTML page for browsing response audit data."""
|
||
return web.Response(content_type="text/html", text=_render_audit_page())
|
||
|
||
|
||
async def handle_api_usage(request):
|
||
"""POST /api/usage — log claim usage for analytics.
|
||
|
||
Body: {"claim_path": "...", "agent": "rio", "context": "telegram-response"}
|
||
Fire-and-forget — returns 200 immediately.
|
||
"""
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
return web.json_response({"error": "invalid JSON"}, status=400)
|
||
|
||
claim_path = body.get("claim_path", "").strip()
|
||
if not claim_path:
|
||
return web.json_response({"error": "claim_path required"}, status=400)
|
||
|
||
agent = body.get("agent", "unknown")
|
||
context = body.get("context", "")
|
||
|
||
# Fire-and-forget write — don't block the response
|
||
try:
|
||
write_conn = _get_write_db()
|
||
if write_conn:
|
||
write_conn.execute(
|
||
"INSERT INTO claim_usage (claim_path, agent, context) VALUES (?, ?, ?)",
|
||
(claim_path, agent, context),
|
||
)
|
||
write_conn.commit()
|
||
write_conn.close()
|
||
except Exception as e:
|
||
logger.warning("Usage log failed (non-fatal): %s", e)
|
||
|
||
return web.json_response({"status": "ok"})
|
||
|
||
|
||
# ─── Dashboard HTML ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def _render_error(message: str) -> str:
|
||
"""Render a minimal error page when DB is unavailable."""
|
||
return f"""<!DOCTYPE html>
|
||
<html><head><meta charset="utf-8"><title>Argus — Error</title>
|
||
<style>body {{ font-family: -apple-system, system-ui, sans-serif; background: #0d1117; color: #c9d1d9; display: flex; align-items: center; justify-content: center; min-height: 100vh; }}
|
||
.err {{ text-align: center; }} h1 {{ color: #f85149; }} p {{ color: #8b949e; }}</style>
|
||
</head><body><div class="err"><h1>Argus</h1><p>{message}</p><p>Check if <code>teleo-pipeline.service</code> is running and pipeline.db exists.</p></div></body></html>"""
|
||
|
||
|
||
def _render_audit_page() -> str:
|
||
"""Render the response audit browser page."""
|
||
return """<!DOCTYPE html>
|
||
<html><head>
|
||
<meta charset="utf-8">
|
||
<title>Argus — Response Audit</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: -apple-system, system-ui, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; }
|
||
h1 { color: #58a6ff; margin-bottom: 8px; font-size: 22px; }
|
||
.subtitle { color: #8b949e; margin-bottom: 20px; font-size: 13px; }
|
||
.filters { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
|
||
.filters input, .filters select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 6px; font-size: 13px; }
|
||
.filters input:focus, .filters select:focus { border-color: #58a6ff; outline: none; }
|
||
.filters button { background: #238636; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||
.filters button:hover { background: #2ea043; }
|
||
.stats { color: #8b949e; font-size: 12px; margin-bottom: 12px; }
|
||
.audit-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||
.audit-card:hover { border-color: #484f58; }
|
||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||
.card-meta { font-size: 12px; color: #8b949e; }
|
||
.card-agent { display: inline-block; background: #1f6feb33; color: #58a6ff; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
||
.card-confidence { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
|
||
.conf-high { background: #23863633; color: #3fb950; }
|
||
.conf-mid { background: #d2992233; color: #d29922; }
|
||
.conf-low { background: #f8514933; color: #f85149; }
|
||
.card-query { font-size: 14px; margin-bottom: 8px; word-break: break-word; }
|
||
.card-query strong { color: #e6edf3; }
|
||
.card-reformulated { font-size: 13px; color: #8b949e; margin-bottom: 8px; font-style: italic; }
|
||
.card-claims { margin-top: 8px; }
|
||
.card-claims summary { cursor: pointer; color: #58a6ff; font-size: 13px; }
|
||
.claim-list { margin-top: 6px; padding-left: 16px; font-size: 12px; color: #8b949e; }
|
||
.claim-list li { margin-bottom: 4px; }
|
||
.score-badge { display: inline-block; background: #30363d; padding: 1px 6px; border-radius: 3px; font-size: 11px; margin-left: 4px; }
|
||
.card-tools { margin-top: 8px; }
|
||
.card-tools summary { cursor: pointer; color: #58a6ff; font-size: 13px; }
|
||
.tool-json { margin-top: 6px; font-size: 11px; color: #8b949e; white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; background: #0d1117; padding: 8px; border-radius: 4px; }
|
||
.pagination { display: flex; gap: 8px; margin-top: 16px; justify-content: center; }
|
||
.pagination button { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 6px 14px; border-radius: 6px; cursor: pointer; }
|
||
.pagination button:hover { background: #30363d; }
|
||
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.empty { text-align: center; color: #484f58; padding: 40px; font-size: 14px; }
|
||
.nav { margin-bottom: 20px; font-size: 13px; }
|
||
.nav a { color: #58a6ff; text-decoration: none; }
|
||
.nav a:hover { text-decoration: underline; }
|
||
.pass-badge { display: inline-block; background: #30363d; padding: 1px 6px; border-radius: 3px; font-size: 11px; margin-left: 4px; }
|
||
.status-ok { display: inline-block; background: #23863633; color: #3fb950; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-left: 6px; }
|
||
.status-degraded { display: inline-block; background: #f8514933; color: #f85149; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-left: 6px; }
|
||
.status-none { display: inline-block; background: #484f5833; color: #8b949e; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-left: 6px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="nav"><a href="/">← Dashboard</a></div>
|
||
<h1>Response Audit</h1>
|
||
<p class="subtitle">Browse agent responses, retrieved claims, and search quality metrics</p>
|
||
|
||
<div class="filters">
|
||
<input type="text" id="query-filter" placeholder="Search queries..." style="width:250px">
|
||
<select id="agent-filter">
|
||
<option value="">All agents</option>
|
||
<option value="rio">rio</option>
|
||
<option value="leo">leo</option>
|
||
<option value="theseus">theseus</option>
|
||
</select>
|
||
<select id="status-filter">
|
||
<option value="">All retrieval</option>
|
||
<option value="keyword_only">keyword_only (degraded)</option>
|
||
<option value="vector+keyword">vector+keyword (healthy)</option>
|
||
<option value="none">no retrieval</option>
|
||
</select>
|
||
<select id="days-filter">
|
||
<option value="1">Last 24h</option>
|
||
<option value="7" selected>Last 7 days</option>
|
||
<option value="30">Last 30 days</option>
|
||
</select>
|
||
<button onclick="loadAudit()">Search</button>
|
||
</div>
|
||
|
||
<div class="stats" id="stats"></div>
|
||
<div id="results"></div>
|
||
<div class="pagination" id="pagination"></div>
|
||
|
||
<script>
|
||
let currentOffset = 0;
|
||
const PAGE_SIZE = 25;
|
||
|
||
async function loadAudit(offset = 0) {
|
||
currentOffset = offset;
|
||
const query = document.getElementById('query-filter').value;
|
||
const agent = document.getElementById('agent-filter').value;
|
||
const status = document.getElementById('status-filter').value;
|
||
const days = document.getElementById('days-filter').value;
|
||
|
||
const params = new URLSearchParams({limit: PAGE_SIZE, offset, days});
|
||
if (query) params.set('query', query);
|
||
if (agent) params.set('agent', agent);
|
||
if (status) params.set('status', status);
|
||
|
||
const resp = await fetch('/api/audit?' + params);
|
||
const data = await resp.json();
|
||
|
||
if (data.error) {
|
||
document.getElementById('results').innerHTML = '<div class="empty">' + data.error + '</div>';
|
||
return;
|
||
}
|
||
|
||
document.getElementById('stats').textContent =
|
||
`${data.total} response${data.total !== 1 ? 's' : ''} found — showing ${offset + 1}–${Math.min(offset + PAGE_SIZE, data.total)}`;
|
||
|
||
if (data.results.length === 0) {
|
||
document.getElementById('results').innerHTML = '<div class="empty">No audit entries found. The bot logs responses as they happen.</div>';
|
||
document.getElementById('pagination').innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const r of data.results) {
|
||
const confClass = r.confidence_score >= 0.7 ? 'conf-high' : r.confidence_score >= 0.4 ? 'conf-mid' : 'conf-low';
|
||
const confLabel = r.confidence_score !== null ? (r.confidence_score * 100).toFixed(0) + '%' : '—';
|
||
|
||
// Claims matched — each has {path, title, score, rank, source}
|
||
let claimsHtml = '';
|
||
if (r.claims_matched && Array.isArray(r.claims_matched) && r.claims_matched.length > 0) {
|
||
const items = r.claims_matched.map(c => {
|
||
if (typeof c === 'string') return '<li>' + esc(c) + '</li>';
|
||
const title = c.title || c.path || '?';
|
||
const score = c.score != null ? ' <span class="score-badge">' + Number(c.score).toFixed(3) + '</span>' : '';
|
||
const src = c.source ? ' <span class="score-badge">' + esc(c.source) + '</span>' : '';
|
||
return '<li>#' + (c.rank || '?') + ' ' + esc(title) + score + src + '</li>';
|
||
}).join('');
|
||
claimsHtml = '<details class="card-claims"><summary>' + r.claims_matched.length + ' claims retrieved</summary><ul class="claim-list">' + items + '</ul></details>';
|
||
} else {
|
||
claimsHtml = '<div style="font-size:12px;color:#484f58">No claims matched</div>';
|
||
}
|
||
|
||
// Retrieval layers
|
||
let layersHtml = '';
|
||
if (r.retrieval_layers_hit && Array.isArray(r.retrieval_layers_hit)) {
|
||
layersHtml = r.retrieval_layers_hit.map(l => ' <span class="pass-badge">' + esc(l) + '</span>').join('');
|
||
}
|
||
|
||
// Retrieval gap warning
|
||
let gapHtml = '';
|
||
if (r.retrieval_gap) {
|
||
gapHtml = '<div style="font-size:12px;color:#f85149;margin-top:4px">Gap: ' + esc(r.retrieval_gap) + '</div>';
|
||
}
|
||
|
||
// Tool calls
|
||
let toolsHtml = '';
|
||
if (r.tool_calls && Array.isArray(r.tool_calls) && r.tool_calls.length > 0) {
|
||
toolsHtml = '<details class="card-tools"><summary>' + r.tool_calls.length + ' retrieval steps</summary><div class="tool-json">' + esc(JSON.stringify(r.tool_calls, null, 2)) + '</div></details>';
|
||
}
|
||
|
||
// Display response preview
|
||
let responseHtml = '';
|
||
if (r.display_response) {
|
||
const preview = r.display_response.length > 300 ? r.display_response.substring(0, 300) + '...' : r.display_response;
|
||
responseHtml = '<details class="card-tools"><summary>Response preview</summary><div class="tool-json">' + esc(preview) + '</div></details>';
|
||
}
|
||
|
||
// Response time
|
||
let timeHtml = r.response_time_ms ? ' <span class="pass-badge">' + (r.response_time_ms / 1000).toFixed(1) + 's</span>' : '';
|
||
|
||
// Retrieval status — prominent color-coded badge
|
||
let statusHtml = '';
|
||
if (r.retrieval_status) {
|
||
const s = r.retrieval_status;
|
||
if (s.startsWith('vector+')) {
|
||
statusHtml = '<span class="status-ok">' + esc(s) + '</span>';
|
||
} else if (s.startsWith('keyword_only')) {
|
||
statusHtml = '<span class="status-degraded">' + esc(s) + '</span>';
|
||
} else if (s === 'none') {
|
||
statusHtml = '<span class="status-none">no retrieval</span>';
|
||
} else {
|
||
statusHtml = '<span class="status-none">' + esc(s) + '</span>';
|
||
}
|
||
}
|
||
|
||
html += '<div class="audit-card">' +
|
||
'<div class="card-header">' +
|
||
'<div><span class="card-agent">' + esc(r.agent || '?') + '</span>' + statusHtml + layersHtml + timeHtml + '</div>' +
|
||
'<div class="card-meta">' + esc(r.user || '') + ' · ' + esc(r.timestamp) + ' <span class="card-confidence ' + confClass + '">' + confLabel + '</span></div>' +
|
||
'</div>' +
|
||
'<div class="card-query"><strong>Q:</strong> ' + esc(r.query || '') + '</div>' +
|
||
gapHtml +
|
||
claimsHtml +
|
||
toolsHtml +
|
||
responseHtml +
|
||
'</div>';
|
||
}
|
||
|
||
document.getElementById('results').innerHTML = html;
|
||
|
||
// Pagination
|
||
const totalPages = Math.ceil(data.total / PAGE_SIZE);
|
||
const currentPage = Math.floor(offset / PAGE_SIZE);
|
||
let pagHtml = '';
|
||
if (currentPage > 0) pagHtml += '<button onclick="loadAudit(' + ((currentPage - 1) * PAGE_SIZE) + ')">← Prev</button>';
|
||
pagHtml += '<span style="color:#8b949e;padding:6px">Page ' + (currentPage + 1) + ' / ' + totalPages + '</span>';
|
||
if (currentPage < totalPages - 1) pagHtml += '<button onclick="loadAudit(' + ((currentPage + 1) * PAGE_SIZE) + ')">Next →</button>';
|
||
document.getElementById('pagination').innerHTML = pagHtml;
|
||
}
|
||
|
||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||
|
||
// Load on page open
|
||
loadAudit();
|
||
</script>
|
||
<!-- Tier 1b: Compute Profile (Max Telemetry) -->
|
||
<div style="margin-top:2rem; border-top:2px solid #10b981; padding-top:1rem;">
|
||
<h2 style="color:#10b981; margin-bottom:1rem;">
|
||
Compute Profile <span style="font-size:0.7em; color:#888;">(Claude Max Telemetry)</span>
|
||
</h2>
|
||
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:1rem; margin-bottom:1.5rem;">
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
|
||
<div style="font-size:0.8rem; color:#888;">Cache Hit Rate</div>
|
||
<div id="t1-cache-rate" style="font-size:1.8rem; font-weight:bold; color:#10b981;">—</div>
|
||
<div style="font-size:0.7rem; color:#666;">prompt tokens from cache</div>
|
||
</div>
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
|
||
<div style="font-size:0.8rem; color:#888;">Avg Latency</div>
|
||
<div id="t1-avg-latency" style="font-size:1.8rem; font-weight:bold; color:#f59e0b;">—</div>
|
||
<div style="font-size:0.7rem; color:#666;">ms per Max call</div>
|
||
</div>
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
|
||
<div style="font-size:0.8rem; color:#888;">Subscription Calls</div>
|
||
<div id="t1-sub-calls" style="font-size:1.8rem; font-weight:bold; color:#8b5cf6;">—</div>
|
||
<div style="font-size:0.7rem; color:#666;">vs <span id="t1-api-calls">—</span> API calls</div>
|
||
</div>
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
|
||
<div style="font-size:0.8rem; color:#888;">API-Equivalent Cost</div>
|
||
<div id="t1-sub-estimate" style="font-size:1.8rem; font-weight:bold; color:#ec4899;">—</div>
|
||
<div style="font-size:0.7rem; color:#666;">saved by Max subscription</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px;">
|
||
<h3 style="margin:0 0 0.5rem; font-size:0.9rem; color:#aaa;">Tokens by Stage & Billing</h3>
|
||
<canvas id="computeStageChart" height="220"></canvas>
|
||
</div>
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px;">
|
||
<h3 style="margin:0 0 0.5rem; font-size:0.9rem; color:#aaa;">Cache Breakdown (Max Calls)</h3>
|
||
<canvas id="cacheChart" height="220"></canvas>
|
||
</div>
|
||
</div>
|
||
<div id="t1-compute-detail" style="color:#888; font-size:0.8rem; margin-top:0.5rem;"></div>
|
||
</div>
|
||
|
||
<script>
|
||
|
||
// --- Tier 1b: Compute Profile ---
|
||
fetch('/api/compute-profile?days=30')
|
||
.then(r => r.json())
|
||
.then(cp => {{
|
||
const sys = cp.system;
|
||
const cache = cp.cache;
|
||
const lat = cp.latency;
|
||
|
||
// Hero cards
|
||
document.getElementById('t1-cache-rate').textContent =
|
||
cache.hit_rate > 0 ? (cache.hit_rate * 100).toFixed(1) + '%' : 'N/A';
|
||
document.getElementById('t1-avg-latency').textContent =
|
||
lat.avg_ms_per_call > 0 ? (lat.avg_ms_per_call / 1000).toFixed(1) + 's' : 'N/A';
|
||
document.getElementById('t1-sub-calls').textContent =
|
||
sys.subscription_calls.toLocaleString();
|
||
document.getElementById('t1-api-calls').textContent =
|
||
sys.api_calls.toLocaleString();
|
||
document.getElementById('t1-sub-estimate').textContent =
|
||
sys.subscription_estimate > 0 ? '$' + sys.subscription_estimate.toFixed(2) : 'N/A';
|
||
|
||
// Compute by stage chart — horizontal bar, colored by billing type
|
||
const stages = cp.by_stage;
|
||
new Chart(document.getElementById('computeStageChart'), {{
|
||
type: 'bar',
|
||
data: {{
|
||
labels: stages.map(s => s.stage.replace('eval_', '').replace(':max', ' \u2295').replace(':openrouter', ' $')),
|
||
datasets: [{{
|
||
label: 'Input Tokens',
|
||
data: stages.map(s => s.input_tokens),
|
||
backgroundColor: stages.map(s => s.billing === 'subscription' ? '#8b5cf6' : '#3b82f6'),
|
||
}}, {{
|
||
label: 'Output Tokens',
|
||
data: stages.map(s => s.output_tokens),
|
||
backgroundColor: stages.map(s => s.billing === 'subscription' ? '#a78bfa' : '#60a5fa'),
|
||
}}, {{
|
||
label: 'Cache Read',
|
||
data: stages.map(s => s.cache_read_tokens),
|
||
backgroundColor: '#10b981',
|
||
}}]
|
||
}},
|
||
options: {{
|
||
indexAxis: 'y',
|
||
responsive: true,
|
||
plugins: {{
|
||
legend: {{ labels: {{ color: '#aaa' }} }},
|
||
tooltip: {{
|
||
callbacks: {{
|
||
afterBody: function(ctx) {{
|
||
const s = stages[ctx[0].dataIndex];
|
||
const lines = ['Calls: ' + s.calls];
|
||
if (s.avg_latency_ms > 0) lines.push('Avg latency: ' + (s.avg_latency_ms / 1000).toFixed(1) + 's');
|
||
if (s.cache_hit_rate > 0) lines.push('Cache hit: ' + (s.cache_hit_rate * 100).toFixed(1) + '%');
|
||
lines.push(s.billing === 'subscription' ? '\u2295 Subscription (flat-rate)' : '$ API (' + s.api_cost.toFixed(4) + ')');
|
||
return lines;
|
||
}}
|
||
}}
|
||
}}
|
||
}},
|
||
scales: {{
|
||
x: {{ stacked: true, ticks: {{ color: '#aaa', callback: v => (v/1000).toFixed(0)+'K' }}, grid: {{ color: '#333' }},
|
||
title: {{ display: true, text: 'Tokens', color: '#888' }} }},
|
||
y: {{ stacked: true, ticks: {{ color: '#aaa' }}, grid: {{ color: '#333' }} }}
|
||
}}
|
||
}}
|
||
}});
|
||
|
||
// Cache breakdown doughnut — only for stages with cache data
|
||
const cacheStages = stages.filter(s => s.cache_read_tokens > 0 || s.cache_write_tokens > 0);
|
||
if (cacheStages.length > 0) {{
|
||
new Chart(document.getElementById('cacheChart'), {{
|
||
type: 'doughnut',
|
||
data: {{
|
||
labels: ['Cache Hits (free)', 'Cache Writes', 'Uncached Input'],
|
||
datasets: [{{
|
||
data: [cache.read_tokens, cache.write_tokens,
|
||
sys.total_tokens - cache.read_tokens - cache.write_tokens],
|
||
backgroundColor: ['#10b981', '#f59e0b', '#6b7280'],
|
||
}}]
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
plugins: {{
|
||
legend: {{ labels: {{ color: '#aaa' }} }},
|
||
tooltip: {{
|
||
callbacks: {{
|
||
label: function(ctx) {{
|
||
const v = ctx.raw;
|
||
const pct = ((v / sys.total_tokens) * 100).toFixed(1);
|
||
return ctx.label + ': ' + (v/1000).toFixed(1) + 'K tokens (' + pct + '%)';
|
||
}}
|
||
}}
|
||
}}
|
||
}}
|
||
}}
|
||
}});
|
||
}} else {{
|
||
document.getElementById('cacheChart').parentElement.innerHTML +=
|
||
'<p style="color:#666; text-align:center; margin-top:2rem;">No cache data yet — populates when Opus re-enabled</p>';
|
||
}}
|
||
|
||
document.getElementById('t1-compute-detail').textContent =
|
||
'Total: ' + (sys.total_tokens/1000).toFixed(0) + 'K tokens across ' +
|
||
sys.total_calls + ' calls. API spend: $' + sys.api_spend.toFixed(2) +
|
||
'. Subscription savings: $' + sys.subscription_estimate.toFixed(2) +
|
||
' (what Max calls would cost at API rates).';
|
||
}})
|
||
.catch(err => {{
|
||
document.getElementById('t1-compute-detail').textContent = 'Failed to load compute profile: ' + err.message;
|
||
}});
|
||
|
||
</script>
|
||
</body></html>"""
|
||
|
||
|
||
def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, domain_breakdown, now) -> str:
|
||
"""Render the full operational dashboard as HTML with Chart.js."""
|
||
|
||
# Prepare chart data
|
||
timestamps = [s["ts"] for s in snapshots]
|
||
throughput_data = [s.get("throughput_1h", 0) for s in snapshots]
|
||
approval_data = [(s.get("approval_rate") or 0) * 100 for s in snapshots]
|
||
open_prs_data = [s.get("open_prs", 0) for s in snapshots]
|
||
merged_data = [s.get("merged_total", 0) for s in snapshots]
|
||
|
||
# Rejection breakdown
|
||
rej_wiki = [s.get("rejection_broken_wiki_links", 0) for s in snapshots]
|
||
rej_schema = [s.get("rejection_frontmatter_schema", 0) for s in snapshots]
|
||
rej_dup = [s.get("rejection_near_duplicate", 0) for s in snapshots]
|
||
rej_conf = [s.get("rejection_confidence", 0) for s in snapshots]
|
||
rej_other = [s.get("rejection_other", 0) for s in snapshots]
|
||
|
||
# Source origins
|
||
origin_agent = [s.get("source_origin_agent", 0) for s in snapshots]
|
||
origin_human = [s.get("source_origin_human", 0) for s in snapshots]
|
||
|
||
# Version annotations
|
||
annotations_js = json.dumps([
|
||
{
|
||
"type": "line",
|
||
"xMin": c["ts"],
|
||
"xMax": c["ts"],
|
||
"borderColor": "#d29922" if c["type"] == "prompt" else "#58a6ff",
|
||
"borderWidth": 1,
|
||
"borderDash": [4, 4],
|
||
"label": {
|
||
"display": True,
|
||
"content": f"{c['type']}: {c.get('to', '?')}",
|
||
"position": "start",
|
||
"backgroundColor": "#161b22",
|
||
"color": "#8b949e",
|
||
"font": {"size": 10},
|
||
},
|
||
}
|
||
for c in changes
|
||
])
|
||
|
||
# Status color helper
|
||
sm = metrics["status_map"]
|
||
ar = metrics["approval_rate"]
|
||
ar_color = "green" if ar > 0.5 else ("yellow" if ar > 0.2 else "red")
|
||
fr_color = "green" if metrics["fix_rate"] > 0.3 else ("yellow" if metrics["fix_rate"] > 0.1 else "red")
|
||
|
||
# Vital signs
|
||
vs_review = vital_signs["review_throughput"]
|
||
vs_status_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(vs_review["status"], "yellow")
|
||
|
||
# Orphan ratio
|
||
vs_orphan = vital_signs.get("orphan_ratio", {})
|
||
orphan_ratio_val = vs_orphan.get("ratio")
|
||
orphan_color = {"healthy": "green", "warning": "yellow", "critical": "red"}.get(vs_orphan.get("status", ""), "")
|
||
orphan_display = f"{orphan_ratio_val:.1%}" if orphan_ratio_val is not None else "—"
|
||
|
||
# Linkage density
|
||
vs_linkage = vital_signs.get("linkage_density") or {}
|
||
linkage_display = f'{vs_linkage.get("avg_outgoing_links", "—")}'
|
||
cross_domain_ratio = vs_linkage.get("cross_domain_ratio")
|
||
cross_domain_color = "green" if cross_domain_ratio and cross_domain_ratio >= 0.15 else ("yellow" if cross_domain_ratio and cross_domain_ratio >= 0.05 else "red") if cross_domain_ratio is not None else ""
|
||
|
||
# Evidence freshness
|
||
vs_fresh = vital_signs.get("evidence_freshness") or {}
|
||
fresh_display = f'{vs_fresh.get("median_age_days", "—")}' if vs_fresh.get("median_age_days") else "—"
|
||
fresh_pct = vs_fresh.get("fresh_30d_pct", 0)
|
||
|
||
# Confidence distribution
|
||
vs_conf = vital_signs.get("confidence_distribution", {})
|
||
|
||
# Rejection reasons table — show unique PRs alongside event count
|
||
reason_rows = "".join(
|
||
f'<tr><td><code>{r["tag"]}</code></td><td>{r["unique_prs"]}</td><td style="color:#8b949e">{r["count"]}</td></tr>'
|
||
for r in metrics["rejection_reasons"]
|
||
)
|
||
|
||
# Domain table
|
||
domain_rows = ""
|
||
for domain, statuses in sorted(metrics["domains"].items()):
|
||
m = statuses.get("merged", 0)
|
||
c = statuses.get("closed", 0)
|
||
o = statuses.get("open", 0)
|
||
total = sum(statuses.values())
|
||
domain_rows += f"<tr><td>{domain}</td><td>{total}</td><td class='green'>{m}</td><td class='red'>{c}</td><td>{o}</td></tr>"
|
||
|
||
# Contributor rows — principal view (default)
|
||
principal_rows = "".join(
|
||
f'<tr><td>{c["handle"]}'
|
||
+ (f'<span style="color:#8b949e;font-size:11px"> ({", ".join(c["agents"])})</span>' if c.get("agents") else "")
|
||
+ f'</td><td>{c["tier"]}</td>'
|
||
f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
|
||
f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
|
||
for c in contributors_principal[:10]
|
||
)
|
||
# Contributor rows — agent view
|
||
agent_rows = "".join(
|
||
f'<tr><td>{c["handle"]}'
|
||
+ (f'<span style="color:#8b949e;font-size:11px"> → {c["principal"]}</span>' if c.get("principal") else "")
|
||
+ f'</td><td>{c["tier"]}</td>'
|
||
f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
|
||
f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
|
||
for c in contributors_agent[:10]
|
||
)
|
||
|
||
# Breaker status
|
||
breaker_rows = ""
|
||
for name, info in metrics["breakers"].items():
|
||
state = info["state"]
|
||
color = "green" if state == "closed" else ("red" if state == "open" else "yellow")
|
||
age = f'{info.get("age_s", "?")}s ago' if "age_s" in info else "-"
|
||
breaker_rows += f'<tr><td>{name}</td><td class="{color}">{state}</td><td>{info["failures"]}</td><td>{age}</td></tr>'
|
||
|
||
# Funnel numbers
|
||
funnel = vital_signs["funnel"]
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en"><head>
|
||
<meta charset="utf-8">
|
||
<title>Argus — Teleo Diagnostics</title>
|
||
<meta http-equiv="refresh" content="60">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.1.0"></script>
|
||
<style>
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: -apple-system, system-ui, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; }}
|
||
.header {{ display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; }}
|
||
h1 {{ color: #58a6ff; font-size: 24px; }}
|
||
.subtitle {{ color: #8b949e; font-size: 13px; }}
|
||
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 20px 0; }}
|
||
.card {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }}
|
||
.card .label {{ color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }}
|
||
.card .value {{ font-size: 28px; font-weight: 700; margin-top: 2px; }}
|
||
.card .detail {{ color: #8b949e; font-size: 11px; margin-top: 2px; }}
|
||
.green {{ color: #3fb950; }}
|
||
.yellow {{ color: #d29922; }}
|
||
.red {{ color: #f85149; }}
|
||
.blue {{ color: #58a6ff; }}
|
||
.chart-container {{ background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; margin: 16px 0; }}
|
||
.chart-container h2 {{ color: #c9d1d9; font-size: 14px; margin-bottom: 12px; }}
|
||
canvas {{ max-height: 260px; }}
|
||
.row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
|
||
@media (max-width: 800px) {{ .row {{ grid-template-columns: 1fr; }} }}
|
||
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
|
||
th {{ color: #8b949e; font-size: 11px; text-transform: uppercase; text-align: left; padding: 6px 10px; border-bottom: 1px solid #30363d; }}
|
||
td {{ padding: 6px 10px; border-bottom: 1px solid #21262d; }}
|
||
code {{ background: #21262d; padding: 2px 6px; border-radius: 3px; font-size: 12px; }}
|
||
.section {{ margin-top: 28px; }}
|
||
.section-title {{ color: #58a6ff; font-size: 15px; font-weight: 600; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid #21262d; }}
|
||
.funnel {{ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
|
||
.funnel-step {{ text-align: center; flex: 1; min-width: 100px; }}
|
||
.funnel-step .num {{ font-size: 24px; font-weight: 700; }}
|
||
.funnel-step .lbl {{ font-size: 11px; color: #8b949e; text-transform: uppercase; }}
|
||
.funnel-arrow {{ color: #30363d; font-size: 20px; }}
|
||
.footer {{ margin-top: 40px; padding-top: 16px; border-top: 1px solid #21262d; color: #484f58; font-size: 11px; }}
|
||
.footer a {{ color: #484f58; }}
|
||
</style>
|
||
</head><body>
|
||
|
||
<div class="header">
|
||
<h1>Argus</h1>
|
||
<span class="subtitle">Teleo Pipeline Diagnostics · {now.strftime("%Y-%m-%d %H:%M UTC")} · auto-refresh 60s</span>
|
||
</div>
|
||
|
||
<!-- Hero Cards -->
|
||
<div class="grid">
|
||
<div class="card">
|
||
<div class="label">Throughput</div>
|
||
<div class="value">{metrics["throughput_1h"]}<span style="font-size:14px;color:#8b949e">/hr</span></div>
|
||
<div class="detail">merged last hour</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Approval Rate (24h)</div>
|
||
<div class="value {ar_color}">{ar:.1%}</div>
|
||
<div class="detail">{metrics["approved_24h"]}/{metrics["evaluated_24h"]} evaluated</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Review Backlog</div>
|
||
<div class="value {vs_status_color}">{vs_review["backlog"]}</div>
|
||
<div class="detail">{vs_review["open_prs"]} open + {vs_review["reviewing_prs"]} reviewing + {vs_review["approved_waiting"]} approved + {vs_review["conflict_prs"]} conflicts</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Merged Total</div>
|
||
<div class="value green">{sm.get("merged", 0)}</div>
|
||
<div class="detail">{sm.get("closed", 0)} closed</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Fix Success</div>
|
||
<div class="value {fr_color}">{metrics["fix_rate"]:.1%}</div>
|
||
<div class="detail">{metrics["fix_succeeded"]}/{metrics["fix_attempted"]} fixed</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Time to Merge</div>
|
||
<div class="value">{f"{metrics['median_ttm_minutes']:.0f}" if metrics["median_ttm_minutes"] else "—"}<span style="font-size:14px;color:#8b949e">min</span></div>
|
||
<div class="detail">median (24h)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pipeline Funnel -->
|
||
<div class="section">
|
||
<div class="section-title">Pipeline Funnel</div>
|
||
<div class="funnel">
|
||
<div class="funnel-step"><div class="num">{funnel["sources_total"]}</div><div class="lbl">Sources</div></div>
|
||
<div class="funnel-arrow">→</div>
|
||
<div class="funnel-step"><div class="num" style="color: #f0883e">{funnel["sources_queued"]}</div><div class="lbl">In Queue</div></div>
|
||
<div class="funnel-arrow">→</div>
|
||
<div class="funnel-step"><div class="num">{funnel["sources_extracted"]}</div><div class="lbl">Extracted</div></div>
|
||
<div class="funnel-arrow">→</div>
|
||
<div class="funnel-step"><div class="num">{funnel["prs_total"]}</div><div class="lbl">PRs Created</div></div>
|
||
<div class="funnel-arrow">→</div>
|
||
<div class="funnel-step"><div class="num green">{funnel["prs_merged"]}</div><div class="lbl">Merged</div></div>
|
||
<div class="funnel-arrow">→</div>
|
||
<div class="funnel-step"><div class="num blue">{funnel["conversion_rate"]:.1%}</div><div class="lbl">Conversion</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Vital Signs (Vida's Five) -->
|
||
{f'''<div class="section">
|
||
<div class="section-title">Knowledge Health (Vida’s Vital Signs)</div>
|
||
<div class="grid">
|
||
<div class="card">
|
||
<div class="label">Orphan Ratio</div>
|
||
<div class="value {orphan_color}">{orphan_display}</div>
|
||
<div class="detail">{vs_orphan.get("count", "?")} / {vs_orphan.get("total", "?")} claims · target <15%</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Avg Links/Claim</div>
|
||
<div class="value">{linkage_display}</div>
|
||
<div class="detail">cross-domain: <span class="{cross_domain_color}">{f"{cross_domain_ratio:.1%}" if cross_domain_ratio is not None else "—"}</span> · target 15-30%</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Evidence Freshness</div>
|
||
<div class="value">{fresh_display}<span style="font-size:14px;color:#8b949e">d median</span></div>
|
||
<div class="detail">{vs_fresh.get("fresh_30d_count", "?")} claims <30d old · {fresh_pct:.0f}% fresh</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Confidence Spread</div>
|
||
<div class="value" style="font-size:16px">{" / ".join(f"{vs_conf.get(k, 0)}" for k in ["proven", "likely", "experimental", "speculative"])}</div>
|
||
<div class="detail">proven / likely / experimental / speculative</div>
|
||
</div>
|
||
</div>
|
||
</div>''' if vital_signs.get("claim_index_status") == "live" else ""}
|
||
|
||
<!-- Charts -->
|
||
<div id="no-chart-data" class="card" style="text-align:center;padding:40px;margin:16px 0;display:none">
|
||
<p style="color:#8b949e">No time-series data yet. Charts will appear once Epimetheus wires <code>record_snapshot()</code> into the pipeline daemon.</p>
|
||
</div>
|
||
<div id="chart-section">
|
||
<div class="row">
|
||
<div class="chart-container">
|
||
<h2>Throughput & Approval Rate</h2>
|
||
<canvas id="throughputChart"></canvas>
|
||
</div>
|
||
<div class="chart-container">
|
||
<h2>Rejection Reasons Over Time</h2>
|
||
<canvas id="rejectionChart"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="chart-container">
|
||
<h2>PR Backlog</h2>
|
||
<canvas id="backlogChart"></canvas>
|
||
</div>
|
||
<div class="chart-container">
|
||
<h2>Source Origins (24h snapshots)</h2>
|
||
<canvas id="originChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tables -->
|
||
<div class="row">
|
||
<div class="section">
|
||
<div class="section-title">Top Rejection Reasons (24h)</div>
|
||
<div class="card">
|
||
<table>
|
||
<tr><th>Issue</th><th>PRs</th><th style="color:#8b949e">Events</th></tr>
|
||
{reason_rows if reason_rows else "<tr><td colspan='2' style='color:#8b949e'>No rejections in 24h</td></tr>"}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<div class="section-title">Circuit Breakers</div>
|
||
<div class="card">
|
||
<table>
|
||
<tr><th>Stage</th><th>State</th><th>Failures</th><th>Last Success</th></tr>
|
||
{breaker_rows if breaker_rows else "<tr><td colspan='4' style='color:#8b949e'>No breaker data</td></tr>"}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="section">
|
||
<div class="section-title">Domain Breakdown</div>
|
||
<div class="card">
|
||
<table>
|
||
<tr><th>Domain</th><th>Total</th><th>Merged</th><th>Closed</th><th>Open</th></tr>
|
||
{domain_rows}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<div class="section-title" style="display:flex;align-items:center;gap:12px">
|
||
Top Contributors (by CI)
|
||
<span style="font-size:11px;font-weight:400">
|
||
<button id="btn-principal" onclick="toggleContribView('principal')" style="background:#21262d;color:#58a6ff;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Human</button>
|
||
<button id="btn-agent" onclick="toggleContribView('agent')" style="background:transparent;color:#8b949e;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Agent</button>
|
||
</span>
|
||
</div>
|
||
<div class="card">
|
||
<table id="contrib-principal">
|
||
<tr><th>Contributor</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
|
||
{principal_rows if principal_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
|
||
</table>
|
||
<table id="contrib-agent" style="display:none">
|
||
<tr><th>Agent</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
|
||
{agent_rows if agent_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Domain Breakdown -->
|
||
<div class="section">
|
||
<div class="section-title">Contributions by Domain</div>
|
||
<div class="card">
|
||
<table>
|
||
<tr><th>Domain</th><th>Knowledge PRs</th><th>Top Contributors</th></tr>
|
||
{"".join(f'''<tr>
|
||
<td style="color:#58a6ff">{domain}</td>
|
||
<td>{stats["knowledge_prs"]}</td>
|
||
<td style="font-size:12px;color:#8b949e">{", ".join(f'{c["handle"]} ({c["claims"]})' for c in stats["contributors"][:3])}</td>
|
||
</tr>''' for domain, stats in sorted(domain_breakdown.items(), key=lambda x: x[1]["knowledge_prs"], reverse=True) if stats["knowledge_prs"] > 0)}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stagnation Alerts -->
|
||
{"" if not vital_signs["domain_activity"]["stagnant"] else f'''
|
||
<div class="section">
|
||
<div class="section-title" style="color:#d29922">Stagnation Alerts</div>
|
||
<div class="card">
|
||
<p style="color:#d29922">Domains with no PR activity in 7 days: <strong>{", ".join(vital_signs["domain_activity"]["stagnant"])}</strong></p>
|
||
</div>
|
||
</div>
|
||
'''}
|
||
|
||
<div class="footer">
|
||
Argus · Teleo Pipeline Diagnostics ·
|
||
<a href="/api/metrics">API: Metrics</a> ·
|
||
<a href="/api/snapshots">Snapshots</a> ·
|
||
<a href="/api/vital-signs">Vital Signs</a> ·
|
||
<a href="/api/contributors">Contributors</a> ·
|
||
<a href="/api/domains">Domains</a> ·
|
||
<a href="/audit">Response Audit</a>
|
||
</div>
|
||
|
||
<script>
|
||
const timestamps = {json.dumps(timestamps)};
|
||
|
||
if (timestamps.length === 0) {{
|
||
document.getElementById('chart-section').style.display = 'none';
|
||
document.getElementById('no-chart-data').style.display = 'block';
|
||
}} else {{
|
||
|
||
const throughputData = {json.dumps(throughput_data)};
|
||
const approvalData = {json.dumps(approval_data)};
|
||
const openPrsData = {json.dumps(open_prs_data)};
|
||
const mergedData = {json.dumps(merged_data)};
|
||
const rejWiki = {json.dumps(rej_wiki)};
|
||
const rejSchema = {json.dumps(rej_schema)};
|
||
const rejDup = {json.dumps(rej_dup)};
|
||
const rejConf = {json.dumps(rej_conf)};
|
||
const rejOther = {json.dumps(rej_other)};
|
||
const originAgent = {json.dumps(origin_agent)};
|
||
const originHuman = {json.dumps(origin_human)};
|
||
const annotations = {annotations_js};
|
||
|
||
const chartDefaults = {{
|
||
color: '#8b949e',
|
||
borderColor: '#30363d',
|
||
font: {{ family: '-apple-system, system-ui, sans-serif' }},
|
||
}};
|
||
Chart.defaults.color = '#8b949e';
|
||
Chart.defaults.borderColor = '#21262d';
|
||
Chart.defaults.font.family = '-apple-system, system-ui, sans-serif';
|
||
Chart.defaults.font.size = 11;
|
||
|
||
// Throughput + Approval Rate (dual axis)
|
||
new Chart(document.getElementById('throughputChart'), {{
|
||
type: 'line',
|
||
data: {{
|
||
labels: timestamps,
|
||
datasets: [
|
||
{{
|
||
label: 'Throughput/hr',
|
||
data: throughputData,
|
||
borderColor: '#58a6ff',
|
||
backgroundColor: 'rgba(88,166,255,0.1)',
|
||
fill: true,
|
||
tension: 0.3,
|
||
yAxisID: 'y',
|
||
pointRadius: 1,
|
||
}},
|
||
{{
|
||
label: 'Approval %',
|
||
data: approvalData,
|
||
borderColor: '#3fb950',
|
||
borderDash: [4, 2],
|
||
tension: 0.3,
|
||
yAxisID: 'y1',
|
||
pointRadius: 1,
|
||
}},
|
||
],
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
interaction: {{ mode: 'index', intersect: false }},
|
||
scales: {{
|
||
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
||
y: {{ position: 'left', title: {{ display: true, text: 'PRs/hr' }}, min: 0 }},
|
||
y1: {{ position: 'right', title: {{ display: true, text: 'Approval %' }}, min: 0, max: 100, grid: {{ drawOnChartArea: false }} }},
|
||
}},
|
||
plugins: {{
|
||
annotation: {{ annotations: annotations }},
|
||
legend: {{ labels: {{ boxWidth: 12 }} }},
|
||
}},
|
||
}},
|
||
}});
|
||
|
||
// Rejection reasons (stacked area)
|
||
new Chart(document.getElementById('rejectionChart'), {{
|
||
type: 'line',
|
||
data: {{
|
||
labels: timestamps,
|
||
datasets: [
|
||
{{ label: 'Wiki Links', data: rejWiki, borderColor: '#f85149', backgroundColor: 'rgba(248,81,73,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||
{{ label: 'Schema', data: rejSchema, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||
{{ label: 'Duplicate', data: rejDup, borderColor: '#8b949e', backgroundColor: 'rgba(139,148,158,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||
{{ label: 'Confidence', data: rejConf, borderColor: '#bc8cff', backgroundColor: 'rgba(188,140,255,0.2)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||
{{ label: 'Other', data: rejOther, borderColor: '#6e7681', backgroundColor: 'rgba(110,118,129,0.15)', fill: true, tension: 0.3, pointRadius: 0 }},
|
||
],
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
scales: {{
|
||
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
||
y: {{ stacked: true, min: 0, title: {{ display: true, text: 'Count (24h)' }} }},
|
||
}},
|
||
plugins: {{
|
||
annotation: {{ annotations: annotations }},
|
||
legend: {{ labels: {{ boxWidth: 12 }} }},
|
||
}},
|
||
}},
|
||
}});
|
||
|
||
// PR Backlog
|
||
new Chart(document.getElementById('backlogChart'), {{
|
||
type: 'line',
|
||
data: {{
|
||
labels: timestamps,
|
||
datasets: [
|
||
{{ label: 'Open PRs', data: openPrsData, borderColor: '#d29922', backgroundColor: 'rgba(210,153,34,0.15)', fill: true, tension: 0.3, pointRadius: 1 }},
|
||
{{ label: 'Merged (total)', data: mergedData, borderColor: '#3fb950', tension: 0.3, pointRadius: 1 }},
|
||
],
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
scales: {{
|
||
x: {{ type: 'time', time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
||
y: {{ min: 0, title: {{ display: true, text: 'PRs' }} }},
|
||
}},
|
||
plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
||
}},
|
||
}});
|
||
|
||
// Source Origins
|
||
new Chart(document.getElementById('originChart'), {{
|
||
type: 'bar',
|
||
data: {{
|
||
labels: timestamps,
|
||
datasets: [
|
||
{{ label: 'Agent', data: originAgent, backgroundColor: '#58a6ff' }},
|
||
{{ label: 'Human', data: originHuman, backgroundColor: '#3fb950' }},
|
||
],
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
scales: {{
|
||
x: {{ type: 'time', stacked: true, time: {{ unit: 'hour', displayFormats: {{ hour: 'MMM d HH:mm' }} }}, grid: {{ display: false }} }},
|
||
y: {{ stacked: true, min: 0, title: {{ display: true, text: 'Sources (24h)' }} }},
|
||
}},
|
||
plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
||
}},
|
||
}});
|
||
|
||
}} // end if (timestamps.length > 0)
|
||
|
||
function toggleContribView(view) {{
|
||
const principal = document.getElementById('contrib-principal');
|
||
const agent = document.getElementById('contrib-agent');
|
||
const btnP = document.getElementById('btn-principal');
|
||
const btnA = document.getElementById('btn-agent');
|
||
if (view === 'agent') {{
|
||
principal.style.display = 'none';
|
||
agent.style.display = '';
|
||
btnA.style.background = '#21262d';
|
||
btnA.style.color = '#58a6ff';
|
||
btnP.style.background = 'transparent';
|
||
btnP.style.color = '#8b949e';
|
||
}} else {{
|
||
principal.style.display = '';
|
||
agent.style.display = 'none';
|
||
btnP.style.background = '#21262d';
|
||
btnP.style.color = '#58a6ff';
|
||
btnA.style.background = 'transparent';
|
||
btnA.style.color = '#8b949e';
|
||
}}
|
||
}}
|
||
</script>
|
||
|
||
<!-- Tier 1: Knowledge Production Metrics -->
|
||
<div class="section">
|
||
<div class="section-title" style="display:flex;align-items:center;gap:12px">
|
||
Knowledge Production
|
||
<span style="font-size:11px;font-weight:400;color:#8b949e">
|
||
The three numbers that matter · <a href="/api/yield" class="nav-link" style="border:none;padding:0">yield</a> ·
|
||
<a href="/api/cost-per-claim" class="nav-link" style="border:none;padding:0">cost</a> ·
|
||
<a href="/api/fix-rates" class="nav-link" style="border:none;padding:0">fix rates</a>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Tier 1 Hero Cards -->
|
||
<div class="grid" id="tier1-cards">
|
||
<div class="card">
|
||
<div class="label">Extraction Yield</div>
|
||
<div class="value" id="t1-yield">—</div>
|
||
<div class="detail" id="t1-yield-detail">loading...</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Cost / Merged Claim</div>
|
||
<div class="value" id="t1-cost">—</div>
|
||
<div class="detail" id="t1-cost-detail">loading...</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="label">Fix Success Rate</div>
|
||
<div class="value" id="t1-fix">—</div>
|
||
<div class="detail" id="t1-fix-detail">loading...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Yield + Cost charts side by side -->
|
||
<div class="row">
|
||
<div class="chart-container">
|
||
<h2>Extraction Yield by Agent (daily)</h2>
|
||
<canvas id="yieldChart"></canvas>
|
||
</div>
|
||
<div class="chart-container">
|
||
<h2>Cost per Merged Claim (daily)</h2>
|
||
<canvas id="costChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fix rates chart + cost breakdown -->
|
||
<div class="row">
|
||
<div class="chart-container">
|
||
<h2>Fix Success by Rejection Reason</h2>
|
||
<canvas id="fixRateChart"></canvas>
|
||
</div>
|
||
<div class="chart-container">
|
||
<h2>Cost by Stage</h2>
|
||
<canvas id="costStageChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
|
||
// --- Tier 1: Knowledge Production Charts ---
|
||
const AGENT_COLORS = {{
|
||
'rio': '#58a6ff',
|
||
'clay': '#3fb950',
|
||
'astra': '#bc8cff',
|
||
'leo': '#d29922',
|
||
'vida': '#f0883e',
|
||
'theseus': '#f85149',
|
||
'epimetheus': '#79c0ff',
|
||
'unknown': '#8b949e',
|
||
}};
|
||
function agentColor(name) {{
|
||
return AGENT_COLORS[name?.toLowerCase()] || '#' + ((name || '').split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) & 0xFFFFFF, 0x556677)).toString(16).padStart(6, '0');
|
||
}}
|
||
|
||
// Fetch all three Tier 1 endpoints
|
||
Promise.all([
|
||
fetch('/api/yield?days=30').then(r => r.json()),
|
||
fetch('/api/cost-per-claim?days=30').then(r => r.json()),
|
||
fetch('/api/fix-rates?days=30').then(r => r.json()),
|
||
]).then(([yieldData, costData, fixData]) => {{
|
||
|
||
// --- Hero cards ---
|
||
const yieldEl = document.getElementById('t1-yield');
|
||
const yieldDetail = document.getElementById('t1-yield-detail');
|
||
if (yieldData.system) {{
|
||
const y = yieldData.system.yield;
|
||
yieldEl.textContent = (y * 100).toFixed(1) + '%';
|
||
yieldEl.className = 'value ' + (y >= 0.3 ? 'green' : y >= 0.15 ? 'yellow' : 'red');
|
||
yieldDetail.textContent = yieldData.system.merged + ' / ' + yieldData.system.evaluated + ' evaluated (30d)';
|
||
}}
|
||
|
||
const costEl = document.getElementById('t1-cost');
|
||
const costDetail = document.getElementById('t1-cost-detail');
|
||
if (costData.system) {{
|
||
const estCost = costData.system.estimated_cost || 0;
|
||
const actualSpend = costData.system.actual_spend || 0;
|
||
const merged = costData.system.merged || 0;
|
||
const cpc = merged > 0 ? estCost / merged : 0;
|
||
if (estCost > 0 || cpc > 0) {{
|
||
costEl.textContent = '$' + cpc.toFixed(3) + '/claim';
|
||
costEl.className = 'value ' + (cpc <= 0.05 ? 'green' : cpc <= 0.20 ? 'yellow' : 'red');
|
||
}} else {{
|
||
costEl.textContent = '—';
|
||
}}
|
||
const parts = [];
|
||
parts.push('$' + estCost.toFixed(2) + ' est');
|
||
if (actualSpend > 0 && actualSpend !== estCost) parts.push('$' + actualSpend.toFixed(2) + ' actual');
|
||
const tt = costData.system.total_tokens;
|
||
if (tt) parts.push((tt/1000000).toFixed(2) + 'M tokens');
|
||
parts.push(merged + ' merged (30d)');
|
||
costDetail.textContent = parts.join(' · ');
|
||
}}
|
||
|
||
const fixEl = document.getElementById('t1-fix');
|
||
const fixDetail = document.getElementById('t1-fix-detail');
|
||
if (fixData.tags && fixData.tags.length > 0) {{
|
||
const totalFixed = fixData.tags.reduce((s, t) => s + t.fixed, 0);
|
||
const totalResolved = fixData.tags.reduce((s, t) => s + t.fixed + t.terminal, 0);
|
||
const overallRate = totalResolved > 0 ? totalFixed / totalResolved : 0;
|
||
fixEl.textContent = (overallRate * 100).toFixed(1) + '%';
|
||
fixEl.className = 'value ' + (overallRate >= 0.5 ? 'green' : overallRate >= 0.25 ? 'yellow' : 'red');
|
||
fixDetail.textContent = totalFixed + ' fixed / ' + totalResolved + ' resolved (30d)';
|
||
}}
|
||
|
||
// --- Yield chart: line per agent over weeks ---
|
||
if (yieldData.daily && yieldData.daily.length > 0) {{
|
||
const days = [...new Set(yieldData.daily.map(r => r.day))].sort();
|
||
const agents = [...new Set(yieldData.daily.map(r => r.agent))];
|
||
const yieldMap = {{}};
|
||
yieldData.daily.forEach(r => {{ yieldMap[r.day + '|' + r.agent] = r.yield; }});
|
||
|
||
new Chart(document.getElementById('yieldChart'), {{
|
||
type: 'line',
|
||
data: {{
|
||
labels: days,
|
||
datasets: agents.map(agent => ({{
|
||
label: agent,
|
||
data: days.map(d => ((yieldMap[d + '|' + agent] || 0) * 100)),
|
||
borderColor: agentColor(agent),
|
||
backgroundColor: agentColor(agent) + '20',
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
fill: false,
|
||
}})),
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
interaction: {{ mode: 'index', intersect: false }},
|
||
scales: {{
|
||
y: {{ min: 0, max: 100, title: {{ display: true, text: 'Yield %' }} }},
|
||
x: {{ grid: {{ display: false }} }},
|
||
}},
|
||
plugins: {{ legend: {{ labels: {{ boxWidth: 12 }} }} }},
|
||
}},
|
||
}});
|
||
}}
|
||
|
||
// --- Compute per claim chart: dual axis (bar=tokens, line=tokens/claim) ---
|
||
if (costData.daily && costData.daily.length > 0) {{
|
||
const cDays = costData.daily.map(r => r.day).reverse();
|
||
const apiCosts = costData.daily.map(r => r.actual_spend || 0).reverse();
|
||
const estCosts = costData.daily.map(r => r.estimated_cost || 0).reverse();
|
||
const cpcWeekly = costData.daily.map(r => r.cost_per_claim || 0).reverse();
|
||
const totalTokensK = costData.daily.map(r => (r.total_tokens || 0) / 1000).reverse();
|
||
|
||
new Chart(document.getElementById('costChart'), {{
|
||
type: 'bar',
|
||
data: {{
|
||
labels: cDays,
|
||
datasets: [
|
||
{{
|
||
label: 'Estimated Cost ($)',
|
||
data: estCosts,
|
||
backgroundColor: 'rgba(63,185,80,0.5)',
|
||
borderColor: '#3fb950',
|
||
borderWidth: 1,
|
||
yAxisID: 'y',
|
||
order: 3,
|
||
}},
|
||
{{
|
||
label: '$/Claim',
|
||
data: cpcWeekly,
|
||
type: 'line',
|
||
borderColor: '#f0883e',
|
||
backgroundColor: 'rgba(240,136,62,0.1)',
|
||
tension: 0.3,
|
||
pointRadius: 4,
|
||
pointBackgroundColor: '#f0883e',
|
||
yAxisID: 'y1',
|
||
order: 1,
|
||
}},
|
||
{{
|
||
label: 'Tokens (K)',
|
||
data: totalTokensK,
|
||
type: 'line',
|
||
borderColor: '#58a6ff',
|
||
borderDash: [4, 4],
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
pointBackgroundColor: '#58a6ff',
|
||
yAxisID: 'y2',
|
||
order: 2,
|
||
}},
|
||
],
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
interaction: {{ mode: 'index', intersect: false }},
|
||
scales: {{
|
||
y: {{ position: 'left', title: {{ display: true, text: 'Estimated Cost ($)' }}, min: 0 }},
|
||
y1: {{ position: 'right', title: {{ display: true, text: '$/Claim' }}, min: 0, grid: {{ drawOnChartArea: false }} }},
|
||
y2: {{ display: false, min: 0 }},
|
||
x: {{ grid: {{ display: false }} }},
|
||
}},
|
||
plugins: {{
|
||
legend: {{ labels: {{ boxWidth: 12 }} }},
|
||
tooltip: {{
|
||
callbacks: {{
|
||
afterBody: function(items) {{
|
||
const idx = items[0].dataIndex;
|
||
return 'Tokens: ' + totalTokensK[idx].toFixed(0) + 'K';
|
||
}}
|
||
}}
|
||
}},
|
||
}},
|
||
}},
|
||
}});
|
||
}}
|
||
|
||
// --- Compute breakdown by stage: doughnut (tokens, not dollars) ---
|
||
if (costData.by_stage && costData.by_stage.length > 0) {{
|
||
const stageColors = ['#58a6ff', '#3fb950', '#d29922', '#f0883e', '#bc8cff', '#f85149', '#8b949e'];
|
||
// Filter out stages with zero tokens
|
||
costData.by_stage = costData.by_stage.filter(s => (s.input_tokens || 0) + (s.output_tokens || 0) > 0);
|
||
const stageCosts = costData.by_stage.map(s => s.estimated_cost || 0);
|
||
const stageTokens = costData.by_stage.map(s => (s.input_tokens || 0) + (s.output_tokens || 0));
|
||
const totalTok = stageTokens.reduce((a, b) => a + b, 0);
|
||
new Chart(document.getElementById('costStageChart'), {{
|
||
type: 'doughnut',
|
||
data: {{
|
||
labels: costData.by_stage.map(s => {{
|
||
let name = s.stage.replace('eval_', '').replace(':openrouter', '').replace(':max', '');
|
||
name = name.charAt(0).toUpperCase() + name.slice(1);
|
||
const billing = s.billing === 'subscription' ? ' (Max)' : ' (API)';
|
||
return name + billing;
|
||
}}),
|
||
datasets: [{{
|
||
data: stageCosts.some(c => c > 0) ? stageCosts : stageTokens,
|
||
backgroundColor: costData.by_stage.map((_, i) => stageColors[i % stageColors.length]),
|
||
borderColor: '#161b22',
|
||
borderWidth: 2,
|
||
}}],
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
plugins: {{
|
||
legend: {{ position: 'right', labels: {{ boxWidth: 12, font: {{ size: 11 }} }} }},
|
||
tooltip: {{
|
||
callbacks: {{
|
||
label: function(ctx) {{
|
||
const idx = ctx.dataIndex;
|
||
const tok = stageTokens[idx];
|
||
const pct = totalTok > 0 ? ((tok / totalTok) * 100).toFixed(1) : '0';
|
||
const s = costData.by_stage[idx];
|
||
const billing = s.billing === 'subscription' ? '⊕ sub' : '$' + (s.estimated_cost || 0).toFixed(4);
|
||
return ctx.label.replace(/ [⊕$]$/, '') + ': ' + (tok/1000).toFixed(0) + 'K tok (' + pct + '%) — ' + billing;
|
||
}}
|
||
}}
|
||
}}
|
||
}},
|
||
}},
|
||
}});
|
||
}}
|
||
|
||
}}).catch(err => {{
|
||
console.error('Tier 1 metrics error:', err);
|
||
['t1-yield', 't1-cost', 't1-fix'].forEach(id => {{
|
||
const el = document.getElementById(id);
|
||
if (el) {{ el.textContent = 'err'; el.className = 'value red'; }}
|
||
}});
|
||
['t1-yield-detail', 't1-cost-detail', 't1-fix-detail'].forEach(id => {{
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = 'Failed to load: ' + err.message;
|
||
}});
|
||
}});
|
||
|
||
</script>
|
||
<!-- Tier 1b: Compute Profile (Max Telemetry) -->
|
||
<div style="margin-top:2rem; border-top:2px solid #10b981; padding-top:1rem;">
|
||
<h2 style="color:#10b981; margin-bottom:1rem;">
|
||
Compute Profile <span style="font-size:0.7em; color:#888;">(Claude Max Telemetry)</span>
|
||
</h2>
|
||
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:1rem; margin-bottom:1.5rem;">
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
|
||
<div style="font-size:0.8rem; color:#888;">Cache Hit Rate</div>
|
||
<div id="t1-cache-rate" style="font-size:1.8rem; font-weight:bold; color:#10b981;">—</div>
|
||
<div style="font-size:0.7rem; color:#666;">prompt tokens from cache</div>
|
||
</div>
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
|
||
<div style="font-size:0.8rem; color:#888;">Avg Latency</div>
|
||
<div id="t1-avg-latency" style="font-size:1.8rem; font-weight:bold; color:#f59e0b;">—</div>
|
||
<div style="font-size:0.7rem; color:#666;">ms per Max call</div>
|
||
</div>
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
|
||
<div style="font-size:0.8rem; color:#888;">Subscription Calls</div>
|
||
<div id="t1-sub-calls" style="font-size:1.8rem; font-weight:bold; color:#8b5cf6;">—</div>
|
||
<div style="font-size:0.7rem; color:#666;">vs <span id="t1-api-calls">—</span> API calls</div>
|
||
</div>
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px; text-align:center;">
|
||
<div style="font-size:0.8rem; color:#888;">API-Equivalent Cost</div>
|
||
<div id="t1-sub-estimate" style="font-size:1.8rem; font-weight:bold; color:#ec4899;">—</div>
|
||
<div style="font-size:0.7rem; color:#666;">saved by Max subscription</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px;">
|
||
<h3 style="margin:0 0 0.5rem; font-size:0.9rem; color:#aaa;">Tokens by Stage & Billing</h3>
|
||
<canvas id="computeStageChart" height="220"></canvas>
|
||
</div>
|
||
<div style="background:#1a1a2e; padding:1rem; border-radius:8px;">
|
||
<h3 style="margin:0 0 0.5rem; font-size:0.9rem; color:#aaa;">Cache Breakdown (Max Calls)</h3>
|
||
<canvas id="cacheChart" height="220"></canvas>
|
||
</div>
|
||
</div>
|
||
<div id="t1-compute-detail" style="color:#888; font-size:0.8rem; margin-top:0.5rem;"></div>
|
||
</div>
|
||
|
||
<script>
|
||
|
||
// --- Tier 1b: Compute Profile ---
|
||
fetch('/api/compute-profile?days=30')
|
||
.then(r => r.json())
|
||
.then(cp => {{
|
||
const sys = cp.system;
|
||
const cache = cp.cache;
|
||
const lat = cp.latency;
|
||
|
||
// Hero cards
|
||
document.getElementById('t1-cache-rate').textContent =
|
||
cache.hit_rate > 0 ? (cache.hit_rate * 100).toFixed(1) + '%' : 'N/A';
|
||
document.getElementById('t1-avg-latency').textContent =
|
||
lat.avg_ms_per_call > 0 ? (lat.avg_ms_per_call / 1000).toFixed(1) + 's' : 'N/A';
|
||
document.getElementById('t1-sub-calls').textContent =
|
||
sys.subscription_calls.toLocaleString();
|
||
document.getElementById('t1-api-calls').textContent =
|
||
sys.api_calls.toLocaleString();
|
||
document.getElementById('t1-sub-estimate').textContent =
|
||
sys.subscription_estimate > 0 ? '$' + sys.subscription_estimate.toFixed(2) : 'N/A';
|
||
|
||
// Compute by stage chart — horizontal bar, colored by billing type
|
||
const stages = cp.by_stage;
|
||
new Chart(document.getElementById('computeStageChart'), {{
|
||
type: 'bar',
|
||
data: {{
|
||
labels: stages.map(s => s.stage.replace('eval_', '').replace(':max', ' \u2295').replace(':openrouter', ' $')),
|
||
datasets: [{{
|
||
label: 'Input Tokens',
|
||
data: stages.map(s => s.input_tokens),
|
||
backgroundColor: stages.map(s => s.billing === 'subscription' ? '#8b5cf6' : '#3b82f6'),
|
||
}}, {{
|
||
label: 'Output Tokens',
|
||
data: stages.map(s => s.output_tokens),
|
||
backgroundColor: stages.map(s => s.billing === 'subscription' ? '#a78bfa' : '#60a5fa'),
|
||
}}, {{
|
||
label: 'Cache Read',
|
||
data: stages.map(s => s.cache_read_tokens),
|
||
backgroundColor: '#10b981',
|
||
}}]
|
||
}},
|
||
options: {{
|
||
indexAxis: 'y',
|
||
responsive: true,
|
||
plugins: {{
|
||
legend: {{ labels: {{ color: '#aaa' }} }},
|
||
tooltip: {{
|
||
callbacks: {{
|
||
afterBody: function(ctx) {{
|
||
const s = stages[ctx[0].dataIndex];
|
||
const lines = ['Calls: ' + s.calls];
|
||
if (s.avg_latency_ms > 0) lines.push('Avg latency: ' + (s.avg_latency_ms / 1000).toFixed(1) + 's');
|
||
if (s.cache_hit_rate > 0) lines.push('Cache hit: ' + (s.cache_hit_rate * 100).toFixed(1) + '%');
|
||
lines.push(s.billing === 'subscription' ? '\u2295 Subscription (flat-rate)' : '$ API (' + s.api_cost.toFixed(4) + ')');
|
||
return lines;
|
||
}}
|
||
}}
|
||
}}
|
||
}},
|
||
scales: {{
|
||
x: {{ stacked: true, ticks: {{ color: '#aaa', callback: v => (v/1000).toFixed(0)+'K' }}, grid: {{ color: '#333' }},
|
||
title: {{ display: true, text: 'Tokens', color: '#888' }} }},
|
||
y: {{ stacked: true, ticks: {{ color: '#aaa' }}, grid: {{ color: '#333' }} }}
|
||
}}
|
||
}}
|
||
}});
|
||
|
||
// Cache breakdown doughnut — only for stages with cache data
|
||
const cacheStages = stages.filter(s => s.cache_read_tokens > 0 || s.cache_write_tokens > 0);
|
||
if (cacheStages.length > 0) {{
|
||
new Chart(document.getElementById('cacheChart'), {{
|
||
type: 'doughnut',
|
||
data: {{
|
||
labels: ['Cache Hits (free)', 'Cache Writes', 'Uncached Input'],
|
||
datasets: [{{
|
||
data: [cache.read_tokens, cache.write_tokens,
|
||
sys.total_tokens - cache.read_tokens - cache.write_tokens],
|
||
backgroundColor: ['#10b981', '#f59e0b', '#6b7280'],
|
||
}}]
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
plugins: {{
|
||
legend: {{ labels: {{ color: '#aaa' }} }},
|
||
tooltip: {{
|
||
callbacks: {{
|
||
label: function(ctx) {{
|
||
const v = ctx.raw;
|
||
const pct = ((v / sys.total_tokens) * 100).toFixed(1);
|
||
return ctx.label + ': ' + (v/1000).toFixed(1) + 'K tokens (' + pct + '%)';
|
||
}}
|
||
}}
|
||
}}
|
||
}}
|
||
}}
|
||
}});
|
||
}} else {{
|
||
document.getElementById('cacheChart').parentElement.innerHTML +=
|
||
'<p style="color:#666; text-align:center; margin-top:2rem;">No cache data yet — populates when Opus re-enabled</p>';
|
||
}}
|
||
|
||
document.getElementById('t1-compute-detail').textContent =
|
||
'Total: ' + (sys.total_tokens/1000).toFixed(0) + 'K tokens across ' +
|
||
sys.total_calls + ' calls. API spend: $' + sys.api_spend.toFixed(2) +
|
||
'. Subscription savings: $' + sys.subscription_estimate.toFixed(2) +
|
||
' (what Max calls would cost at API rates).';
|
||
}})
|
||
.catch(err => {{
|
||
document.getElementById('t1-compute-detail').textContent = 'Failed to load compute profile: ' + err.message;
|
||
}});
|
||
|
||
</script>
|
||
</body></html>"""
|
||
|
||
|
||
# ─── App factory ─────────────────────────────────────────────────────────────
|
||
|
||
from alerting_routes import register_alerting_routes
|
||
from tier1_routes import register_tier1_routes
|
||
|
||
# 4-page dashboard imports
|
||
from dashboard_ops import render_ops_page
|
||
from dashboard_health import render_health_page
|
||
from dashboard_agents import render_agents_page
|
||
from dashboard_epistemic import render_epistemic_page
|
||
from dashboard_prs import render_prs_page
|
||
from dashboard_routes import register_dashboard_routes
|
||
# requires CWD = deploy dir
|
||
|
||
def _conn_from_app(app):
|
||
import sqlite3
|
||
conn = app["db"]
|
||
try:
|
||
conn.execute("SELECT 1")
|
||
except sqlite3.Error:
|
||
conn = _get_db()
|
||
app["db"] = conn
|
||
return conn
|
||
|
||
|
||
|
||
|
||
|
||
# ─── 4-page dashboard route handlers ───────────────────────────────────────
|
||
|
||
async def handle_ops_page(request):
|
||
"""GET /ops — Pipeline Operations page."""
|
||
try:
|
||
conn = _conn(request)
|
||
metrics = _current_metrics(conn)
|
||
snapshots = _snapshot_history(conn, days=7)
|
||
changes = _version_changes(conn, days=30)
|
||
vital_signs = _compute_vital_signs(conn)
|
||
except Exception as e:
|
||
return web.Response(text=_render_error(f"Database error: {e}"), content_type="text/html", status=503)
|
||
now = datetime.now(timezone.utc)
|
||
return web.Response(text=render_ops_page(metrics, snapshots, changes, vital_signs, now), content_type="text/html")
|
||
|
||
|
||
async def handle_health_page(request):
|
||
"""GET /health — Knowledge Health page."""
|
||
try:
|
||
conn = _conn(request)
|
||
vital_signs = _compute_vital_signs(conn)
|
||
domain_breakdown = _domain_breakdown(conn)
|
||
except Exception as e:
|
||
return web.Response(text=_render_error(f"Database error: {e}"), content_type="text/html", status=503)
|
||
now = datetime.now(timezone.utc)
|
||
return web.Response(text=render_health_page(vital_signs, domain_breakdown, now), content_type="text/html")
|
||
|
||
|
||
async def handle_agents_page(request):
|
||
"""GET /agents — Agent Performance page."""
|
||
try:
|
||
conn = _conn(request)
|
||
contributors_principal = _contributor_leaderboard(conn, limit=10, view="principal")
|
||
contributors_agent = _contributor_leaderboard(conn, limit=10, view="agent")
|
||
except Exception as e:
|
||
return web.Response(text=_render_error(f"Database error: {e}"), content_type="text/html", status=503)
|
||
now = datetime.now(timezone.utc)
|
||
return web.Response(text=render_agents_page(contributors_principal, contributors_agent, now), content_type="text/html")
|
||
|
||
|
||
async def handle_epistemic_page(request):
|
||
"""GET /epistemic — Epistemic Integrity page."""
|
||
try:
|
||
conn = _conn(request)
|
||
vital_signs = _compute_vital_signs(conn)
|
||
except Exception as e:
|
||
return web.Response(text=_render_error(f"Database error: {e}"), content_type="text/html", status=503)
|
||
now = datetime.now(timezone.utc)
|
||
return web.Response(text=render_epistemic_page(vital_signs, now), content_type="text/html")
|
||
|
||
|
||
|
||
|
||
async def handle_prs_page(request):
|
||
"""GET /prs — PR Lifecycle page."""
|
||
from datetime import datetime, timezone
|
||
now = datetime.now(timezone.utc)
|
||
return web.Response(text=render_prs_page(now), content_type="text/html")
|
||
|
||
async def handle_root_redirect(request):
|
||
"""GET / — redirect to /ops."""
|
||
raise web.HTTPFound("/ops")
|
||
|
||
|
||
def create_app() -> web.Application:
|
||
app = web.Application(middlewares=[auth_middleware])
|
||
app["db"] = _get_db()
|
||
app["api_key"] = _load_secret(API_KEY_FILE)
|
||
if app["api_key"]:
|
||
logger.info("API key auth enabled (protected endpoints require X-Api-Key)")
|
||
else:
|
||
logger.info("No API key configured — all endpoints open")
|
||
# Root redirects to /ops (legacy dashboard still at /legacy)
|
||
app.router.add_get("/", handle_root_redirect)
|
||
app.router.add_get("/prs", handle_prs_page)
|
||
app.router.add_get("/ops", handle_ops_page)
|
||
app.router.add_get("/health", handle_health_page)
|
||
app.router.add_get("/agents", handle_agents_page)
|
||
app.router.add_get("/epistemic", handle_epistemic_page)
|
||
app.router.add_get("/legacy", handle_dashboard) # keep old dashboard for rollback
|
||
app.router.add_get("/api/metrics", handle_api_metrics)
|
||
app.router.add_get("/api/snapshots", handle_api_snapshots)
|
||
app.router.add_get("/api/vital-signs", handle_api_vital_signs)
|
||
app.router.add_get("/api/contributors", handle_api_contributors)
|
||
app.router.add_get("/api/domains", handle_api_domains)
|
||
app.router.add_get("/api/search", handle_api_search)
|
||
app.router.add_get("/api/audit", handle_api_audit)
|
||
app.router.add_get("/audit", handle_audit_page)
|
||
app.router.add_post("/api/usage", handle_api_usage)
|
||
# Alerting - active monitoring endpoints
|
||
register_alerting_routes(app, lambda: _conn_from_app(app))
|
||
register_tier1_routes(app, lambda: _conn_from_app(app))
|
||
register_dashboard_routes(app, lambda: _conn_from_app(app))
|
||
register_review_queue_routes(app)
|
||
register_daily_digest_routes(app, db_path=str(DB_PATH))
|
||
# Response audit - cost tracking + reasoning traces
|
||
app["db_path"] = str(DB_PATH)
|
||
register_response_audit_routes(app)
|
||
app.on_cleanup.append(_cleanup)
|
||
return app
|
||
|
||
|
||
async def _cleanup(app):
|
||
app["db"].close()
|
||
|
||
|
||
def main():
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||
logger.info("Argus diagnostics starting on port %d, DB: %s", PORT, DB_PATH)
|
||
app = create_app()
|
||
web.run_app(app, host="0.0.0.0", port=PORT)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|