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>
929 lines
34 KiB
Python
929 lines
34 KiB
Python
"""New API endpoints for the 4-page dashboard.
|
|
|
|
Endpoints:
|
|
GET /api/stage-times — median dwell time per pipeline stage
|
|
GET /api/herfindahl — domain concentration index
|
|
GET /api/agent-state — live agent-state from filesystem
|
|
GET /api/extraction-yield-by-domain — sources→claims conversion per domain
|
|
GET /api/agents-dashboard — batched agent performance payload
|
|
|
|
Owner: Argus
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import statistics
|
|
import time
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from aiohttp import web
|
|
|
|
logger = logging.getLogger("argus.dashboard_routes")
|
|
|
|
# ─── Claim-index cache (60s TTL) ───────────────────────────────────────────
|
|
|
|
_claim_index_cache: dict | None = None
|
|
_claim_index_ts: float = 0
|
|
CLAIM_INDEX_TTL = 60 # seconds
|
|
|
|
CLAIM_INDEX_URL = os.environ.get("CLAIM_INDEX_URL", "http://localhost:8080/claim-index")
|
|
AGENT_STATE_DIR = Path(os.environ.get("AGENT_STATE_DIR", "/opt/teleo-eval/agent-state"))
|
|
|
|
|
|
def get_claim_index() -> dict | None:
|
|
"""Fetch claim-index with 60s cache."""
|
|
global _claim_index_cache, _claim_index_ts
|
|
now = time.monotonic()
|
|
if _claim_index_cache is not None and (now - _claim_index_ts) < CLAIM_INDEX_TTL:
|
|
return _claim_index_cache
|
|
try:
|
|
with urllib.request.urlopen(CLAIM_INDEX_URL, timeout=5) as resp:
|
|
data = json.loads(resp.read())
|
|
_claim_index_cache = data
|
|
_claim_index_ts = now
|
|
return data
|
|
except Exception as e:
|
|
logger.warning("Failed to fetch claim-index: %s", e)
|
|
# Return stale cache if available
|
|
return _claim_index_cache
|
|
|
|
|
|
# ─── GET /api/stage-times ──────────────────────────────────────────────────
|
|
|
|
async def handle_stage_times(request):
|
|
"""Median dwell time per pipeline stage from audit_log timestamps.
|
|
|
|
Stages: discover → validate → evaluate → merge
|
|
Returns median minutes between consecutive stages.
|
|
"""
|
|
conn = request.app["_get_conn"]()
|
|
hours = int(request.query.get("hours", "24"))
|
|
|
|
# Get per-PR event timestamps
|
|
rows = conn.execute(
|
|
"""SELECT json_extract(detail, '$.pr') as pr, event, timestamp
|
|
FROM audit_log
|
|
WHERE timestamp > datetime('now', ? || ' hours')
|
|
AND json_extract(detail, '$.pr') IS NOT NULL
|
|
ORDER BY json_extract(detail, '$.pr'), timestamp""",
|
|
(f"-{hours}",),
|
|
).fetchall()
|
|
|
|
# Group by PR
|
|
pr_events: dict[int, list] = {}
|
|
for r in rows:
|
|
pr = r["pr"]
|
|
if pr not in pr_events:
|
|
pr_events[pr] = []
|
|
pr_events[pr].append({"event": r["event"], "ts": r["timestamp"]})
|
|
|
|
# Compute stage dwell times
|
|
stage_pairs = [
|
|
("pr_discovered", "tier0_complete", "Ingest → Validate"),
|
|
("tier0_complete", "approved", "Validate → Approve"),
|
|
("tier0_complete", "domain_rejected", "Validate → Reject"),
|
|
("approved", "merged", "Approve → Merge"),
|
|
]
|
|
|
|
stage_times = {}
|
|
for start_event, end_event, label in stage_pairs:
|
|
durations = []
|
|
for pr, events in pr_events.items():
|
|
start_ts = None
|
|
end_ts = None
|
|
for e in events:
|
|
if e["event"] == start_event and start_ts is None:
|
|
start_ts = e["ts"]
|
|
if e["event"] == end_event and end_ts is None:
|
|
end_ts = e["ts"]
|
|
if start_ts and end_ts:
|
|
try:
|
|
s = datetime.fromisoformat(start_ts)
|
|
e = datetime.fromisoformat(end_ts)
|
|
mins = (e - s).total_seconds() / 60
|
|
if mins >= 0:
|
|
durations.append(mins)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if durations:
|
|
stage_times[label] = {
|
|
"median_minutes": round(statistics.median(durations), 1),
|
|
"p90_minutes": round(sorted(durations)[int(len(durations) * 0.9)], 1) if len(durations) >= 5 else None,
|
|
"count": len(durations),
|
|
}
|
|
|
|
return web.json_response({"hours": hours, "stages": stage_times})
|
|
|
|
|
|
# ─── GET /api/herfindahl ──────────────────────────────────────────────────
|
|
|
|
async def handle_herfindahl(request):
|
|
"""Domain concentration index (Herfindahl-Hirschman).
|
|
|
|
HHI = sum of (domain_share^2). 1.0 = single domain, lower = more diverse.
|
|
"""
|
|
conn = request.app["_get_conn"]()
|
|
days = int(request.query.get("days", "30"))
|
|
|
|
rows = conn.execute(
|
|
"""SELECT domain, COUNT(*) as cnt
|
|
FROM prs WHERE status='merged' AND domain IS NOT NULL
|
|
AND merged_at > datetime('now', ? || ' days')
|
|
GROUP BY domain""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
if not rows:
|
|
return web.json_response({"hhi": 0, "domains": [], "days": days})
|
|
|
|
total = sum(r["cnt"] for r in rows)
|
|
domains = []
|
|
hhi = 0
|
|
for r in rows:
|
|
share = r["cnt"] / total
|
|
hhi += share ** 2
|
|
domains.append({
|
|
"domain": r["domain"],
|
|
"count": r["cnt"],
|
|
"share": round(share, 4),
|
|
})
|
|
|
|
domains.sort(key=lambda x: x["count"], reverse=True)
|
|
|
|
# Interpret: HHI < 0.15 = diverse, 0.15-0.25 = moderate, >0.25 = concentrated
|
|
status = "diverse" if hhi < 0.15 else ("moderate" if hhi < 0.25 else "concentrated")
|
|
|
|
return web.json_response({
|
|
"hhi": round(hhi, 4),
|
|
"status": status,
|
|
"domains": domains,
|
|
"total_merged": total,
|
|
"days": days,
|
|
})
|
|
|
|
|
|
# ─── GET /api/agent-state ─────────────────────────────────────────────────
|
|
|
|
async def handle_agent_state(request):
|
|
"""Read live agent-state from filesystem. 6 agents, ~1KB each."""
|
|
if not AGENT_STATE_DIR.exists():
|
|
return web.json_response({"error": "agent-state directory not found", "path": str(AGENT_STATE_DIR)}, status=404)
|
|
|
|
agents = {}
|
|
for agent_dir in sorted(AGENT_STATE_DIR.iterdir()):
|
|
if not agent_dir.is_dir():
|
|
continue
|
|
name = agent_dir.name
|
|
state = {"name": name}
|
|
|
|
# metrics.json
|
|
metrics_file = agent_dir / "metrics.json"
|
|
if metrics_file.exists():
|
|
try:
|
|
m = json.loads(metrics_file.read_text())
|
|
state["last_active"] = m.get("updated_at")
|
|
state["metrics"] = m
|
|
except (json.JSONDecodeError, OSError):
|
|
state["metrics_error"] = True
|
|
|
|
# tasks.json
|
|
tasks_file = agent_dir / "tasks.json"
|
|
if tasks_file.exists():
|
|
try:
|
|
t = json.loads(tasks_file.read_text())
|
|
state["tasks"] = t if isinstance(t, list) else []
|
|
state["task_count"] = len(state["tasks"])
|
|
except (json.JSONDecodeError, OSError):
|
|
state["tasks"] = []
|
|
|
|
# session.json
|
|
session_file = agent_dir / "session.json"
|
|
if session_file.exists():
|
|
try:
|
|
s = json.loads(session_file.read_text())
|
|
state["session"] = s
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
# inbox depth
|
|
inbox_dir = agent_dir / "inbox"
|
|
if inbox_dir.exists() and inbox_dir.is_dir():
|
|
state["inbox_depth"] = len(list(inbox_dir.iterdir()))
|
|
else:
|
|
state["inbox_depth"] = 0
|
|
|
|
agents[name] = state
|
|
|
|
return web.json_response({"agents": agents, "agent_count": len(agents)})
|
|
|
|
|
|
# ─── GET /api/extraction-yield-by-domain ──────────────────────────────────
|
|
|
|
async def handle_extraction_yield_by_domain(request):
|
|
"""Sources → claims conversion rate per domain."""
|
|
conn = request.app["_get_conn"]()
|
|
days = int(request.query.get("days", "30"))
|
|
|
|
# Sources per domain (approximate from PR source_path domain)
|
|
source_counts = conn.execute(
|
|
"""SELECT domain, COUNT(DISTINCT source_url) as sources
|
|
FROM sources s
|
|
JOIN prs p ON p.source_path LIKE '%' || s.url || '%'
|
|
WHERE s.created_at > datetime('now', ? || ' days')
|
|
GROUP BY domain""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
# Fallback: simpler query if the join doesn't work well
|
|
merged_by_domain = conn.execute(
|
|
"""SELECT domain, COUNT(*) as merged
|
|
FROM prs WHERE status='merged' AND domain IS NOT NULL
|
|
AND merged_at > datetime('now', ? || ' days')
|
|
GROUP BY domain""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
sources_by_domain = conn.execute(
|
|
"""SELECT domain, COUNT(*) as total_prs,
|
|
SUM(CASE WHEN status='merged' THEN 1 ELSE 0 END) as merged
|
|
FROM prs WHERE domain IS NOT NULL
|
|
AND created_at > datetime('now', ? || ' days')
|
|
GROUP BY domain""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
domains = []
|
|
for r in sources_by_domain:
|
|
total = r["total_prs"] or 0
|
|
merged = r["merged"] or 0
|
|
domains.append({
|
|
"domain": r["domain"],
|
|
"total_prs": total,
|
|
"merged": merged,
|
|
"yield": round(merged / total, 3) if total else 0,
|
|
})
|
|
|
|
domains.sort(key=lambda x: x["merged"], reverse=True)
|
|
return web.json_response({"days": days, "domains": domains})
|
|
|
|
|
|
# ─── GET /api/agents-dashboard ─────────────────────────────────────────────
|
|
|
|
async def handle_agents_dashboard(request):
|
|
"""Batched agent performance payload for Page 3.
|
|
|
|
Returns per-agent: merged count, rejection rate, yield, CI score,
|
|
top rejection reasons, contribution trend (weekly).
|
|
All in one response to avoid N client-side fetches.
|
|
"""
|
|
conn = request.app["_get_conn"]()
|
|
days = int(request.query.get("days", "30"))
|
|
|
|
# Per-agent merged + rejected counts
|
|
agent_stats = conn.execute(
|
|
"""SELECT
|
|
COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent')) as agent,
|
|
COUNT(*) as evaluated,
|
|
SUM(CASE WHEN event='approved' THEN 1 ELSE 0 END) as approved,
|
|
SUM(CASE WHEN event IN ('changes_requested','domain_rejected','tier05_rejected') THEN 1 ELSE 0 END) as rejected
|
|
FROM audit_log
|
|
WHERE stage='evaluate'
|
|
AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected')
|
|
AND timestamp > datetime('now', ? || ' days')
|
|
AND COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent')) IS NOT NULL
|
|
GROUP BY agent""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
agents = {}
|
|
for r in agent_stats:
|
|
name = r["agent"]
|
|
ev = r["evaluated"] or 0
|
|
ap = r["approved"] or 0
|
|
rj = r["rejected"] or 0
|
|
agents[name] = {
|
|
"evaluated": ev,
|
|
"approved": ap,
|
|
"rejected": rj,
|
|
"yield": round(ap / ev, 3) if ev else 0,
|
|
"rejection_rate": round(rj / ev, 3) if ev else 0,
|
|
}
|
|
|
|
# Per-agent top rejection reasons from prs.eval_issues (Epimetheus correction 2026-04-02)
|
|
tag_rows = conn.execute(
|
|
"""SELECT agent, value as tag, COUNT(*) as cnt
|
|
FROM prs, json_each(prs.eval_issues)
|
|
WHERE eval_issues IS NOT NULL AND eval_issues != '[]'
|
|
AND agent IS NOT NULL
|
|
AND created_at > datetime('now', ? || ' days')
|
|
GROUP BY agent, tag
|
|
ORDER BY agent, cnt DESC""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
for r in tag_rows:
|
|
name = r["agent"]
|
|
if name in agents:
|
|
if "top_rejections" not in agents[name]:
|
|
agents[name]["top_rejections"] = []
|
|
if len(agents[name]["top_rejections"]) < 5:
|
|
agents[name]["top_rejections"].append({"tag": r["tag"], "count": r["cnt"]})
|
|
|
|
# Weekly contribution trend per agent
|
|
weekly = conn.execute(
|
|
"""SELECT
|
|
COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent')) as agent,
|
|
strftime('%Y-W%W', timestamp) as week,
|
|
SUM(CASE WHEN event='approved' THEN 1 ELSE 0 END) as merged,
|
|
COUNT(*) as evaluated
|
|
FROM audit_log
|
|
WHERE stage='evaluate'
|
|
AND event IN ('approved','changes_requested','domain_rejected','tier05_rejected')
|
|
AND timestamp > datetime('now', ? || ' days')
|
|
AND COALESCE(json_extract(detail, '$.agent'), json_extract(detail, '$.domain_agent')) IS NOT NULL
|
|
GROUP BY agent, week
|
|
ORDER BY agent, week""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
for r in weekly:
|
|
name = r["agent"]
|
|
if name in agents:
|
|
if "weekly_trend" not in agents[name]:
|
|
agents[name]["weekly_trend"] = []
|
|
agents[name]["weekly_trend"].append({
|
|
"week": r["week"],
|
|
"merged": r["merged"] or 0,
|
|
"evaluated": r["evaluated"] or 0,
|
|
})
|
|
|
|
# CI scores from contributors table
|
|
weights = {"sourcer": 0.15, "extractor": 0.05, "challenger": 0.35, "synthesizer": 0.25, "reviewer": 0.20}
|
|
try:
|
|
contribs = conn.execute(
|
|
"SELECT handle, sourcer_count, extractor_count, challenger_count, "
|
|
"synthesizer_count, reviewer_count, claims_merged, tier FROM contributors"
|
|
).fetchall()
|
|
for c in contribs:
|
|
name = c["handle"]
|
|
if name not in agents:
|
|
agents[name] = {}
|
|
ci = sum((c[f"{role}_count"] or 0) * w for role, w in weights.items())
|
|
agents[name]["ci_score"] = round(ci, 2)
|
|
agents[name]["claims_merged"] = c["claims_merged"] or 0
|
|
agents[name]["tier"] = c["tier"]
|
|
except sqlite3.Error:
|
|
pass
|
|
|
|
return web.json_response({"days": days, "agents": agents})
|
|
|
|
|
|
# ─── GET /api/cascade-coverage ────────────────────────────────────────────
|
|
|
|
async def handle_cascade_coverage(request):
|
|
"""Cascade coverage from audit_log stage='cascade' events.
|
|
|
|
Returns: triggered count, by-agent breakdown, claims affected.
|
|
"""
|
|
conn = request.app["_get_conn"]()
|
|
days = int(request.query.get("days", "30"))
|
|
|
|
triggered = conn.execute(
|
|
"""SELECT
|
|
json_extract(detail, '$.agent') as agent,
|
|
COUNT(*) as cnt,
|
|
SUM(json_array_length(json_extract(detail, '$.source_claims'))) as claims_affected
|
|
FROM audit_log
|
|
WHERE stage='cascade' AND event='cascade_triggered'
|
|
AND timestamp > datetime('now', ? || ' days')
|
|
GROUP BY agent""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
summaries = conn.execute(
|
|
"""SELECT
|
|
SUM(json_extract(detail, '$.notifications_sent')) as total_notifications,
|
|
COUNT(*) as total_merges_with_cascade
|
|
FROM audit_log
|
|
WHERE stage='cascade' AND event='cascade_summary'
|
|
AND timestamp > datetime('now', ? || ' days')""",
|
|
(f"-{days}",),
|
|
).fetchone()
|
|
|
|
reviewed = conn.execute(
|
|
"""SELECT COUNT(*) as cnt
|
|
FROM audit_log
|
|
WHERE stage='cascade' AND event='cascade_reviewed'
|
|
AND timestamp > datetime('now', ? || ' days')""",
|
|
(f"-{days}",),
|
|
).fetchone()
|
|
|
|
total_triggered = sum(r["cnt"] for r in triggered)
|
|
total_reviewed = reviewed["cnt"] if reviewed else 0
|
|
completion_rate = round(total_reviewed / total_triggered, 3) if total_triggered else None
|
|
|
|
by_agent = [
|
|
{"agent": r["agent"], "triggered": r["cnt"], "claims_affected": r["claims_affected"] or 0}
|
|
for r in triggered
|
|
]
|
|
|
|
return web.json_response({
|
|
"days": days,
|
|
"total_triggered": total_triggered,
|
|
"total_reviewed": total_reviewed,
|
|
"completion_rate": completion_rate,
|
|
"total_notifications": summaries["total_notifications"] if summaries else 0,
|
|
"merges_with_cascade": summaries["total_merges_with_cascade"] if summaries else 0,
|
|
"by_agent": by_agent,
|
|
})
|
|
|
|
|
|
# ─── GET /api/review-summary ─────────────────────────────────────────────
|
|
|
|
async def handle_review_summary(request):
|
|
"""Structured review data from review_records table (migration v12).
|
|
|
|
Cleaner than audit_log parsing — structured outcome, rejection_reason,
|
|
disagreement_type columns.
|
|
"""
|
|
conn = request.app["_get_conn"]()
|
|
days = int(request.query.get("days", "30"))
|
|
|
|
# Check if table exists and has data
|
|
try:
|
|
total = conn.execute(
|
|
"SELECT COUNT(*) as cnt FROM review_records WHERE reviewed_at > datetime('now', ? || ' days')",
|
|
(f"-{days}",),
|
|
).fetchone()["cnt"]
|
|
except Exception:
|
|
return web.json_response({"error": "review_records table not available", "populated": False})
|
|
|
|
if total == 0:
|
|
return web.json_response({"populated": False, "total": 0, "days": days})
|
|
|
|
# Outcome breakdown
|
|
outcomes = conn.execute(
|
|
"""SELECT outcome, COUNT(*) as cnt
|
|
FROM review_records
|
|
WHERE reviewed_at > datetime('now', ? || ' days')
|
|
GROUP BY outcome""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
# Rejection reasons
|
|
reasons = conn.execute(
|
|
"""SELECT rejection_reason, COUNT(*) as cnt
|
|
FROM review_records
|
|
WHERE rejection_reason IS NOT NULL
|
|
AND reviewed_at > datetime('now', ? || ' days')
|
|
GROUP BY rejection_reason ORDER BY cnt DESC""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
# Disagreement types
|
|
disagreements = conn.execute(
|
|
"""SELECT disagreement_type, COUNT(*) as cnt
|
|
FROM review_records
|
|
WHERE disagreement_type IS NOT NULL
|
|
AND reviewed_at > datetime('now', ? || ' days')
|
|
GROUP BY disagreement_type ORDER BY cnt DESC""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
# Per-reviewer breakdown
|
|
reviewers = conn.execute(
|
|
"""SELECT reviewer,
|
|
SUM(CASE WHEN outcome='approved' THEN 1 ELSE 0 END) as approved,
|
|
SUM(CASE WHEN outcome='approved-with-changes' THEN 1 ELSE 0 END) as approved_with_changes,
|
|
SUM(CASE WHEN outcome='rejected' THEN 1 ELSE 0 END) as rejected,
|
|
COUNT(*) as total
|
|
FROM review_records
|
|
WHERE reviewed_at > datetime('now', ? || ' days')
|
|
GROUP BY reviewer ORDER BY total DESC""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
# Per-domain breakdown
|
|
domains = conn.execute(
|
|
"""SELECT domain,
|
|
SUM(CASE WHEN outcome='rejected' THEN 1 ELSE 0 END) as rejected,
|
|
COUNT(*) as total
|
|
FROM review_records
|
|
WHERE domain IS NOT NULL
|
|
AND reviewed_at > datetime('now', ? || ' days')
|
|
GROUP BY domain ORDER BY total DESC""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
return web.json_response({
|
|
"populated": True,
|
|
"days": days,
|
|
"total": total,
|
|
"outcomes": {r["outcome"]: r["cnt"] for r in outcomes},
|
|
"rejection_reasons": [{"reason": r["rejection_reason"], "count": r["cnt"]} for r in reasons],
|
|
"disagreement_types": [{"type": r["disagreement_type"], "count": r["cnt"]} for r in disagreements],
|
|
"reviewers": [
|
|
{"reviewer": r["reviewer"], "approved": r["approved"], "approved_with_changes": r["approved_with_changes"],
|
|
"rejected": r["rejected"], "total": r["total"]}
|
|
for r in reviewers
|
|
],
|
|
"domains": [
|
|
{"domain": r["domain"], "rejected": r["rejected"], "total": r["total"],
|
|
"rejection_rate": round(r["rejected"] / r["total"], 3) if r["total"] else 0}
|
|
for r in domains
|
|
],
|
|
})
|
|
|
|
|
|
# ─── Trace endpoint ────────────────────────────────────────────────────────
|
|
|
|
|
|
async def handle_trace(request: web.Request) -> web.Response:
|
|
"""Return the full lifecycle of a source/PR through the pipeline.
|
|
|
|
GET /api/trace/1234 → all audit_log + review_records + costs for PR 1234.
|
|
One thread, every stage, chronological.
|
|
"""
|
|
trace_id = request.match_info["trace_id"]
|
|
get_conn = request.app["_get_conn"]
|
|
conn = get_conn()
|
|
|
|
# Audit log events (the backbone)
|
|
# Try trace_id first, fall back to PR number in detail JSON
|
|
events = conn.execute(
|
|
"""SELECT timestamp, stage, event, detail
|
|
FROM audit_log
|
|
WHERE trace_id = ?
|
|
ORDER BY timestamp""",
|
|
(trace_id,),
|
|
).fetchall()
|
|
|
|
if not events:
|
|
# Fallback: match by PR number in detail JSON (for rows without trace_id)
|
|
events = conn.execute(
|
|
"""SELECT timestamp, stage, event, detail
|
|
FROM audit_log
|
|
WHERE CAST(json_extract(detail, '$.pr') AS TEXT) = ?
|
|
ORDER BY timestamp""",
|
|
(trace_id,),
|
|
).fetchall()
|
|
|
|
# Review records for this PR
|
|
reviews = conn.execute(
|
|
"""SELECT reviewed_at, reviewer, reviewer_model, outcome,
|
|
rejection_reason, disagreement_type, notes, claim_path
|
|
FROM review_records
|
|
WHERE pr_number = ?
|
|
ORDER BY reviewed_at""",
|
|
(trace_id,),
|
|
).fetchall()
|
|
|
|
# PR metadata
|
|
pr = conn.execute(
|
|
"""SELECT number, source_path, domain, agent, tier, status,
|
|
origin, created_at, merged_at
|
|
FROM prs
|
|
WHERE number = ?""",
|
|
(trace_id,),
|
|
).fetchone()
|
|
|
|
result = {
|
|
"trace_id": trace_id,
|
|
"pr": dict(pr) if pr else None,
|
|
"timeline": [
|
|
{"timestamp": r[0], "stage": r[1], "event": r[2],
|
|
"detail": json.loads(r[3]) if r[3] else None}
|
|
for r in events
|
|
],
|
|
"reviews": [
|
|
{"reviewed_at": r[0], "reviewer": r[1], "model": r[2],
|
|
"outcome": r[3], "rejection_reason": r[4],
|
|
"disagreement_type": r[5], "notes": r[6], "claim_path": r[7]}
|
|
for r in reviews
|
|
],
|
|
}
|
|
|
|
return web.json_response(result)
|
|
|
|
|
|
# ─── GET /api/growth ──────────────────────────────────────────────────────
|
|
|
|
async def handle_growth(request):
|
|
"""Cumulative growth of sources, PRs, and merged claims over time.
|
|
|
|
Returns daily data points with running totals for each series.
|
|
"""
|
|
conn = request.app["_get_conn"]()
|
|
days = int(request.query.get("days", "90"))
|
|
|
|
# Daily new sources
|
|
source_rows = conn.execute(
|
|
"""SELECT date(created_at) as day, COUNT(*) as cnt
|
|
FROM sources
|
|
WHERE created_at > datetime('now', ? || ' days')
|
|
GROUP BY day ORDER BY day""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
# Daily new PRs
|
|
pr_rows = conn.execute(
|
|
"""SELECT date(created_at) as day, COUNT(*) as cnt
|
|
FROM prs
|
|
WHERE created_at > datetime('now', ? || ' days')
|
|
GROUP BY day ORDER BY day""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
# Daily merged PRs
|
|
merged_rows = conn.execute(
|
|
"""SELECT date(merged_at) as day, COUNT(*) as cnt
|
|
FROM prs
|
|
WHERE status = 'merged' AND merged_at IS NOT NULL
|
|
AND merged_at > datetime('now', ? || ' days')
|
|
GROUP BY day ORDER BY day""",
|
|
(f"-{days}",),
|
|
).fetchall()
|
|
|
|
# Get totals BEFORE the window for correct cumulative baseline
|
|
source_base = conn.execute(
|
|
"SELECT COUNT(*) as cnt FROM sources WHERE created_at <= datetime('now', ? || ' days')",
|
|
(f"-{days}",),
|
|
).fetchone()["cnt"]
|
|
|
|
pr_base = conn.execute(
|
|
"SELECT COUNT(*) as cnt FROM prs WHERE created_at <= datetime('now', ? || ' days')",
|
|
(f"-{days}",),
|
|
).fetchone()["cnt"]
|
|
|
|
merged_base = conn.execute(
|
|
"""SELECT COUNT(*) as cnt FROM prs
|
|
WHERE status = 'merged' AND merged_at IS NOT NULL
|
|
AND merged_at <= datetime('now', ? || ' days')""",
|
|
(f"-{days}",),
|
|
).fetchone()["cnt"]
|
|
|
|
# Collect all unique dates
|
|
all_dates = sorted(set(
|
|
[r["day"] for r in source_rows] +
|
|
[r["day"] for r in pr_rows] +
|
|
[r["day"] for r in merged_rows]
|
|
))
|
|
|
|
# Build lookup dicts
|
|
src_by_day = {r["day"]: r["cnt"] for r in source_rows}
|
|
pr_by_day = {r["day"]: r["cnt"] for r in pr_rows}
|
|
mrg_by_day = {r["day"]: r["cnt"] for r in merged_rows}
|
|
|
|
# Build cumulative arrays
|
|
dates = []
|
|
sources_cum = []
|
|
prs_cum = []
|
|
merged_cum = []
|
|
|
|
s_total = source_base
|
|
p_total = pr_base
|
|
m_total = merged_base
|
|
|
|
for day in all_dates:
|
|
s_total += src_by_day.get(day, 0)
|
|
p_total += pr_by_day.get(day, 0)
|
|
m_total += mrg_by_day.get(day, 0)
|
|
dates.append(day)
|
|
sources_cum.append(s_total)
|
|
prs_cum.append(p_total)
|
|
merged_cum.append(m_total)
|
|
|
|
return web.json_response({
|
|
"days": days,
|
|
"dates": dates,
|
|
"sources": sources_cum,
|
|
"prs": prs_cum,
|
|
"merged": merged_cum,
|
|
"current": {
|
|
"sources": s_total,
|
|
"prs": p_total,
|
|
"merged": m_total,
|
|
},
|
|
})
|
|
|
|
|
|
import re
|
|
_DATE_PREFIX_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-?")
|
|
|
|
# ─── GET /api/pr-lifecycle ────────────────────────────────────────────────
|
|
|
|
async def handle_pr_lifecycle(request):
|
|
"""All PRs with eval rounds, reviews, and time-to-merge in one payload.
|
|
|
|
Returns: summary KPIs + per-PR array for the table.
|
|
Joins prs + audit_log (eval rounds) + review_records.
|
|
"""
|
|
conn = request.app["_get_conn"]()
|
|
days = int(request.query.get("days", "30"))
|
|
|
|
day_clause = "AND p.created_at > datetime('now', ? || ' days')" if days < 9999 else ""
|
|
params = (f"-{days}",) if days < 9999 else ()
|
|
|
|
# Base PR data
|
|
pr_rows = conn.execute(
|
|
f"""SELECT p.number, p.agent, p.domain, p.tier, p.status,
|
|
p.created_at, p.merged_at, p.leo_verdict, p.description,
|
|
p.domain_agent, p.domain_model, p.branch
|
|
FROM prs p
|
|
WHERE 1=1 {day_clause}
|
|
ORDER BY p.number DESC""",
|
|
params,
|
|
).fetchall()
|
|
|
|
# Eval round counts per PR (from audit_log)
|
|
eval_rows = conn.execute(
|
|
f"""SELECT CAST(json_extract(detail, '$.pr') AS INTEGER) as pr,
|
|
COUNT(*) as rounds
|
|
FROM audit_log
|
|
WHERE stage = 'evaluate'
|
|
AND event IN ('approved', 'changes_requested', 'domain_rejected', 'tier05_rejected')
|
|
AND json_extract(detail, '$.pr') IS NOT NULL
|
|
GROUP BY pr""",
|
|
).fetchall()
|
|
eval_map = {r["pr"]: r["rounds"] for r in eval_rows}
|
|
|
|
# Review outcomes per PR (from review_records)
|
|
review_rows = conn.execute(
|
|
"""SELECT pr_number, outcome,
|
|
GROUP_CONCAT(DISTINCT reviewer) as reviewers,
|
|
COUNT(*) as review_count
|
|
FROM review_records
|
|
GROUP BY pr_number, outcome""",
|
|
).fetchall()
|
|
review_map = {}
|
|
for r in review_rows:
|
|
pr = r["pr_number"]
|
|
if pr not in review_map:
|
|
review_map[pr] = {"outcomes": [], "reviewers": set(), "count": 0}
|
|
review_map[pr]["outcomes"].append(r["outcome"])
|
|
if r["reviewers"]:
|
|
review_map[pr]["reviewers"].update(r["reviewers"].split(","))
|
|
review_map[pr]["count"] += r["review_count"]
|
|
|
|
# Review snippets for closed PRs — from review_text or issues list
|
|
snippet_rows = conn.execute(
|
|
"""SELECT CAST(json_extract(detail, '$.pr') AS INTEGER) as pr,
|
|
COALESCE(
|
|
json_extract(detail, '$.review_text'),
|
|
json_extract(detail, '$.domain_review_text'),
|
|
json_extract(detail, '$.leo_review_text')
|
|
) as review_text,
|
|
json_extract(detail, '$.issues') as issues,
|
|
json_extract(detail, '$.leo') as leo_verdict
|
|
FROM audit_log
|
|
WHERE stage = 'evaluate'
|
|
AND event IN ('domain_rejected', 'changes_requested')
|
|
AND json_extract(detail, '$.pr') IS NOT NULL
|
|
ORDER BY timestamp DESC""",
|
|
).fetchall()
|
|
snippet_map = {}
|
|
for r in snippet_rows:
|
|
pr = r["pr"]
|
|
if pr not in snippet_map:
|
|
if r["review_text"]:
|
|
text = r["review_text"].strip()
|
|
lines = [ln.strip() for ln in text.split("\n") if ln.strip() and not ln.strip().startswith("#")]
|
|
snippet_map[pr] = lines[0][:200] if lines else text[:200]
|
|
elif r["issues"]:
|
|
try:
|
|
issues = json.loads(r["issues"]) if isinstance(r["issues"], str) else r["issues"]
|
|
if isinstance(issues, list) and issues:
|
|
snippet_map[pr] = "Issues: " + ", ".join(str(i).replace("_", " ") for i in issues)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
|
|
# Build PR list
|
|
prs = []
|
|
ttm_values = []
|
|
round_values = []
|
|
merged_count = 0
|
|
closed_count = 0
|
|
open_count = 0
|
|
|
|
for r in pr_rows:
|
|
pr_num = r["number"]
|
|
ttm = None
|
|
if r["merged_at"] and r["created_at"]:
|
|
try:
|
|
created = datetime.fromisoformat(r["created_at"])
|
|
merged = datetime.fromisoformat(r["merged_at"])
|
|
ttm = (merged - created).total_seconds() / 60
|
|
if ttm >= 0:
|
|
ttm_values.append(ttm)
|
|
else:
|
|
ttm = None
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
rounds = eval_map.get(pr_num, 0)
|
|
if rounds > 0:
|
|
round_values.append(rounds)
|
|
|
|
review_info = review_map.get(pr_num)
|
|
|
|
status = r["status"] or "unknown"
|
|
if status == "merged":
|
|
merged_count += 1
|
|
elif status == "closed":
|
|
closed_count += 1
|
|
elif status == "open":
|
|
open_count += 1
|
|
|
|
# Claims count from pipe-separated description titles
|
|
desc = r["description"] or ""
|
|
claims_count = desc.count("|") + 1 if desc.strip() else 1
|
|
|
|
# Summary: first claim title from description, fallback to branch name
|
|
summary = None
|
|
if desc.strip():
|
|
first_title = desc.split("|")[0].strip()
|
|
summary = first_title[:120] if first_title else None
|
|
if not summary:
|
|
branch = r["branch"] or ""
|
|
# Use prefix as category if present: "extract/...", "reweave/...", etc.
|
|
prefix = ""
|
|
if "/" in branch:
|
|
prefix = branch.split("/", 1)[0]
|
|
branch = branch.split("/", 1)[1]
|
|
# Strip date prefix like "2026-04-06-" or "2026-02-00-"
|
|
branch = _DATE_PREFIX_RE.sub("", branch)
|
|
# Strip trailing hash suffix like "-116d" or "-2cb1"
|
|
branch = re.sub(r"-[0-9a-f]{4}$", "", branch)
|
|
if branch:
|
|
summary = branch.replace("-", " ").replace("_", " ").strip()[:120]
|
|
elif prefix:
|
|
summary = prefix # "reweave", "ingestion", etc.
|
|
|
|
prs.append({
|
|
"number": pr_num,
|
|
"agent": r["agent"],
|
|
"domain": r["domain"],
|
|
"tier": r["tier"],
|
|
"status": status,
|
|
"claims_count": claims_count,
|
|
"eval_rounds": rounds,
|
|
"ttm_minutes": round(ttm, 1) if ttm is not None else None,
|
|
"created_at": r["created_at"],
|
|
"merged_at": r["merged_at"],
|
|
"leo_verdict": r["leo_verdict"],
|
|
"review_count": review_info["count"] if review_info else 0,
|
|
"summary": summary,
|
|
"description": desc if desc.strip() else None,
|
|
"review_snippet": snippet_map.get(pr_num),
|
|
})
|
|
|
|
# Summary KPIs
|
|
ttm_values.sort()
|
|
round_values.sort()
|
|
|
|
def median(vals):
|
|
if not vals:
|
|
return None
|
|
n = len(vals)
|
|
if n % 2 == 0:
|
|
return (vals[n // 2 - 1] + vals[n // 2]) / 2
|
|
return vals[n // 2]
|
|
|
|
def p90(vals):
|
|
if len(vals) < 5:
|
|
return None
|
|
return vals[int(len(vals) * 0.9)]
|
|
|
|
return web.json_response({
|
|
"days": days,
|
|
"total": len(prs),
|
|
"merged": merged_count,
|
|
"closed": closed_count,
|
|
"open": open_count,
|
|
"median_ttm": round(median(ttm_values), 1) if median(ttm_values) is not None else None,
|
|
"p90_ttm": round(p90(ttm_values), 1) if p90(ttm_values) is not None else None,
|
|
"median_rounds": round(median(round_values), 1) if median(round_values) is not None else None,
|
|
"max_rounds": max(round_values) if round_values else None,
|
|
"prs": prs,
|
|
})
|
|
|
|
|
|
# ─── Registration ──────────────────────────────────────────────────────────
|
|
|
|
def register_dashboard_routes(app: web.Application, get_conn):
|
|
"""Register new dashboard API routes."""
|
|
app["_get_conn"] = get_conn
|
|
app.router.add_get("/api/stage-times", handle_stage_times)
|
|
app.router.add_get("/api/herfindahl", handle_herfindahl)
|
|
app.router.add_get("/api/agent-state", handle_agent_state)
|
|
app.router.add_get("/api/extraction-yield-by-domain", handle_extraction_yield_by_domain)
|
|
app.router.add_get("/api/agents-dashboard", handle_agents_dashboard)
|
|
app.router.add_get("/api/cascade-coverage", handle_cascade_coverage)
|
|
app.router.add_get("/api/review-summary", handle_review_summary)
|
|
app.router.add_get("/api/trace/{trace_id}", handle_trace)
|
|
app.router.add_get("/api/growth", handle_growth)
|
|
app.router.add_get("/api/pr-lifecycle", handle_pr_lifecycle)
|