"""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)