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>
208 lines
7.2 KiB
Python
208 lines
7.2 KiB
Python
"""Daily digest — sends Cory a summary of all Tier 3 activity at 8am London time.
|
|
|
|
Aggregates: merged claims (with insight summaries), pipeline metrics, agent activity,
|
|
pending review items. Runs as a scheduled job in bot.py.
|
|
|
|
Epimetheus owns this module.
|
|
"""
|
|
|
|
import logging
|
|
import sqlite3
|
|
from datetime import datetime, timezone, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
logger = logging.getLogger("telegram.digest")
|
|
|
|
LONDON_TZ = ZoneInfo("Europe/London")
|
|
DIGEST_HOUR_LONDON = 8 # 8am London time (auto-adjusts for BST/GMT)
|
|
|
|
|
|
def next_digest_time() -> datetime:
|
|
"""Calculate the next 8am London time as a UTC datetime.
|
|
|
|
Handles BST/GMT transitions automatically via zoneinfo.
|
|
"""
|
|
now = datetime.now(LONDON_TZ)
|
|
target = now.replace(hour=DIGEST_HOUR_LONDON, minute=0, second=0, microsecond=0)
|
|
if target <= now:
|
|
target += timedelta(days=1)
|
|
return target.astimezone(timezone.utc)
|
|
|
|
|
|
def _get_merged_claims_24h(conn: sqlite3.Connection) -> list[dict]:
|
|
"""Get PRs merged in the last 24 hours with domain and branch info."""
|
|
rows = conn.execute(
|
|
"""SELECT number, branch, domain, agent, commit_type, merged_at, description
|
|
FROM prs
|
|
WHERE merged_at > datetime('now', '-24 hours')
|
|
AND status = 'merged'
|
|
ORDER BY merged_at DESC""",
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def _get_pipeline_metrics_24h(conn: sqlite3.Connection) -> dict:
|
|
"""Get pipeline activity metrics for the last 24 hours."""
|
|
total_merged = conn.execute(
|
|
"SELECT COUNT(*) FROM prs WHERE merged_at > datetime('now', '-24 hours') AND status = 'merged'"
|
|
).fetchone()[0]
|
|
|
|
total_closed = conn.execute(
|
|
"SELECT COUNT(*) FROM prs WHERE status = 'closed' AND created_at > datetime('now', '-24 hours')"
|
|
).fetchone()[0]
|
|
|
|
total_conflict = conn.execute(
|
|
"SELECT COUNT(*) FROM prs WHERE status IN ('conflict', 'conflict_permanent') AND created_at > datetime('now', '-24 hours')"
|
|
).fetchone()[0]
|
|
|
|
total_open = conn.execute(
|
|
"SELECT COUNT(*) FROM prs WHERE status IN ('open', 'reviewing', 'approved', 'merging')"
|
|
).fetchone()[0]
|
|
|
|
# Approval rate (last 24h)
|
|
evaluated = conn.execute(
|
|
"SELECT COUNT(*) FROM prs WHERE leo_verdict IN ('approve', 'request_changes') AND created_at > datetime('now', '-24 hours')"
|
|
).fetchone()[0]
|
|
approved = conn.execute(
|
|
"SELECT COUNT(*) FROM prs WHERE leo_verdict = 'approve' AND created_at > datetime('now', '-24 hours')"
|
|
).fetchone()[0]
|
|
approval_rate = (approved / evaluated * 100) if evaluated > 0 else 0
|
|
|
|
return {
|
|
"merged": total_merged,
|
|
"closed": total_closed,
|
|
"conflict": total_conflict,
|
|
"open": total_open,
|
|
"evaluated": evaluated,
|
|
"approved": approved,
|
|
"approval_rate": approval_rate,
|
|
}
|
|
|
|
|
|
def _get_agent_activity_24h(conn: sqlite3.Connection) -> dict[str, int]:
|
|
"""Get PR count by agent for the last 24 hours."""
|
|
rows = conn.execute(
|
|
"""SELECT agent, COUNT(*) as cnt
|
|
FROM prs
|
|
WHERE created_at > datetime('now', '-24 hours')
|
|
AND agent IS NOT NULL
|
|
GROUP BY agent
|
|
ORDER BY cnt DESC""",
|
|
).fetchall()
|
|
return {r["agent"]: r["cnt"] for r in rows}
|
|
|
|
|
|
def _get_pending_review_count(conn: sqlite3.Connection) -> int:
|
|
"""Count PRs awaiting review."""
|
|
return conn.execute(
|
|
"SELECT COUNT(*) FROM prs WHERE status IN ('open', 'reviewing')"
|
|
).fetchone()[0]
|
|
|
|
|
|
def _extract_claim_title(branch: str) -> str:
|
|
"""Extract a human-readable claim title from a branch name.
|
|
|
|
Branch format: extract/source-slug or agent/description
|
|
"""
|
|
# Strip prefix (extract/, research/, theseus/, etc.)
|
|
parts = branch.split("/", 1)
|
|
slug = parts[1] if len(parts) > 1 else parts[0]
|
|
# Convert slug to readable title
|
|
return slug.replace("-", " ").replace("_", " ").title()
|
|
|
|
|
|
|
|
def format_digest(
|
|
merged_claims: list[dict],
|
|
metrics: dict,
|
|
agent_activity: dict[str, int],
|
|
pending_review: int,
|
|
) -> str:
|
|
"""Format the daily digest message."""
|
|
now = datetime.now(timezone.utc)
|
|
date_str = now.strftime("%Y-%m-%d")
|
|
|
|
parts = [f"DAILY DIGEST — {date_str}", ""]
|
|
|
|
# Merged claims section
|
|
if merged_claims:
|
|
# Group by domain
|
|
by_domain: dict[str, list] = {}
|
|
for claim in merged_claims:
|
|
domain = claim.get("domain") or "unknown"
|
|
by_domain.setdefault(domain, []).append(claim)
|
|
|
|
parts.append(f"CLAIMS MERGED ({len(merged_claims)})")
|
|
for domain, claims in sorted(by_domain.items()):
|
|
for c in claims:
|
|
# Use real description from frontmatter if available, fall back to slug title
|
|
desc = c.get("description")
|
|
if desc:
|
|
# Take first description if multiple (pipe-delimited)
|
|
display = desc.split(" | ")[0]
|
|
if len(display) > 120:
|
|
display = display[:117] + "..."
|
|
else:
|
|
display = _extract_claim_title(c.get("branch", "unknown"))
|
|
commit_type = c.get("commit_type", "")
|
|
type_tag = f"[{commit_type}] " if commit_type else ""
|
|
parts.append(f" {type_tag}{display} ({domain})")
|
|
parts.append("")
|
|
else:
|
|
parts.extend(["CLAIMS MERGED (0)", " No claims merged in the last 24h", ""])
|
|
|
|
# Pipeline metrics
|
|
success_rate = 0
|
|
total_attempted = metrics["merged"] + metrics["closed"] + metrics["conflict"]
|
|
if total_attempted > 0:
|
|
success_rate = metrics["merged"] / total_attempted * 100
|
|
|
|
parts.append("PIPELINE")
|
|
parts.append(f" Merged: {metrics['merged']} | Closed: {metrics['closed']} | Conflicts: {metrics['conflict']}")
|
|
parts.append(f" Success rate: {success_rate:.0f}% | Approval rate: {metrics['approval_rate']:.0f}%")
|
|
parts.append(f" Open PRs: {metrics['open']}")
|
|
parts.append("")
|
|
|
|
# Agent activity
|
|
if agent_activity:
|
|
parts.append("AGENTS")
|
|
for agent, count in agent_activity.items():
|
|
parts.append(f" {agent}: {count} PRs")
|
|
parts.append("")
|
|
else:
|
|
parts.extend(["AGENTS", " No agent activity in the last 24h", ""])
|
|
|
|
# Pending review
|
|
if pending_review > 0:
|
|
parts.append(f"PENDING YOUR REVIEW: {pending_review}")
|
|
else:
|
|
parts.append("PENDING YOUR REVIEW: 0")
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
async def send_daily_digest(context):
|
|
"""Send daily digest to admin chat. Scheduled job."""
|
|
conn = context.bot_data.get("approval_conn")
|
|
admin_chat_id = context.bot_data.get("admin_chat_id")
|
|
|
|
if not conn or not admin_chat_id:
|
|
logger.debug("Digest skipped — no DB connection or admin chat ID")
|
|
return
|
|
|
|
try:
|
|
merged = _get_merged_claims_24h(conn)
|
|
metrics = _get_pipeline_metrics_24h(conn)
|
|
activity = _get_agent_activity_24h(conn)
|
|
pending = _get_pending_review_count(conn)
|
|
|
|
text = format_digest(merged, metrics, activity, pending)
|
|
|
|
await context.bot.send_message(
|
|
chat_id=admin_chat_id,
|
|
text=text,
|
|
)
|
|
logger.info("Daily digest sent (%d claims, %d agents active)",
|
|
len(merged), len(activity))
|
|
except Exception as e:
|
|
logger.error("Failed to send daily digest: %s", e)
|