| 1 | #!/usr/bin/env python3 |
| 2 | """Morning briefing generator for last30days. |
| 3 | |
| 4 | Synthesizes accumulated findings into formatted briefings. |
| 5 | The Python script collects the data; the agent (via SKILL.md) does the |
| 6 | beautiful synthesis. This script provides the structured data. |
| 7 | |
| 8 | Usage: |
| 9 | python3 briefing.py generate # Daily briefing data |
| 10 | python3 briefing.py generate --weekly # Weekly digest data |
| 11 | python3 briefing.py show [--date DATE] # Show saved briefing |
| 12 | """ |
| 13 | |
| 14 | import argparse |
| 15 | import json |
| 16 | import sys |
| 17 | from datetime import datetime, timedelta, timezone |
| 18 | from pathlib import Path |
| 19 | |
| 20 | SCRIPT_DIR = Path(__file__).parent.resolve() |
| 21 | sys.path.insert(0, str(SCRIPT_DIR)) |
| 22 | |
| 23 | import store |
| 24 | |
| 25 | BRIEFS_DIR = Path.home() / ".local" / "share" / "last30days" / "briefs" |
| 26 | |
| 27 | |
| 28 | def _parse_sqlite_utc_timestamp(value: str) -> datetime: |
| 29 | return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) |
| 30 | |
| 31 | |
| 32 | def generate_daily(since: str = None) -> dict: |
| 33 | """Generate daily briefing data. |
| 34 | |
| 35 | Returns structured data for the agent to synthesize into a beautiful briefing. |
| 36 | """ |
| 37 | store.init_db() |
| 38 | topics = store.list_topics() |
| 39 | |
| 40 | if not topics: |
| 41 | return { |
| 42 | "status": "no_topics", |
| 43 | "message": "No watchlist topics yet. Add one with: last30days watch add \"your topic\"", |
| 44 | } |
| 45 | |
| 46 | enabled = [t for t in topics if t["enabled"]] |
| 47 | if not enabled: |
| 48 | return { |
| 49 | "status": "no_enabled", |
| 50 | "message": "All topics are paused. Enable a topic to generate briefings.", |
| 51 | } |
| 52 | |
| 53 | # Default: findings since yesterday |
| 54 | if not since: |
| 55 | since = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") |
| 56 | |
| 57 | briefing_topics = [] |
| 58 | total_new = 0 |
| 59 | |
| 60 | for topic in enabled: |
| 61 | findings = store.get_new_findings(topic["id"], since) |
| 62 | last_run = topic.get("last_run") |
| 63 | last_status = topic.get("last_status", "unknown") |
| 64 | |
| 65 | # Calculate staleness |
| 66 | stale = False |
| 67 | hours_ago = None |
| 68 | if last_run: |
| 69 | try: |
| 70 | run_dt = _parse_sqlite_utc_timestamp(last_run) |
| 71 | hours_ago = (datetime.now(timezone.utc) - run_dt).total_seconds() / 3600 |
| 72 | stale = hours_ago > 36 # Stale if > 36 hours |
| 73 | except (ValueError, TypeError): |
| 74 | stale = True |
| 75 | |
| 76 | topic_data = { |
| 77 | "name": topic["name"], |
| 78 | "findings": findings, |
| 79 | "new_count": len(findings), |
| 80 | "last_run": last_run, |
| 81 | "last_status": last_status, |
| 82 | "stale": stale, |
| 83 | "hours_ago": round(hours_ago, 1) if hours_ago else None, |
| 84 | } |
| 85 | |
| 86 | # Extract top finding by engagement |
| 87 | if findings: |
| 88 | top = max(findings, key=lambda f: f.get("engagement_score") or 0) |
| 89 | topic_data["top_finding"] = { |
| 90 | "title": top.get("source_title", ""), |
| 91 | "source": top.get("source", ""), |
| 92 | "author": top.get("author", ""), |
| 93 | "engagement": top.get("engagement_score", 0), |
| 94 | "content": top.get("content", "")[:300], |
| 95 | } |
| 96 | |
| 97 | briefing_topics.append(topic_data) |
| 98 | total_new += len(findings) |
| 99 | |
| 100 | # Cost info |
| 101 | daily_cost = store.get_daily_cost() |
| 102 | budget = float(store.get_setting("daily_budget", "5.00")) |
| 103 | |
| 104 | # Find the single top finding across all topics (for TL;DR) |
| 105 | all_findings = [] |
| 106 | for t in briefing_topics: |
| 107 | for f in t["findings"]: |
| 108 | f["_topic"] = t["name"] |
| 109 | all_findings.append(f) |
| 110 | |
| 111 | top_overall = None |
| 112 | if all_findings: |
| 113 | top_overall = max(all_findings, key=lambda f: f.get("engagement_score") or 0) |
| 114 | |
| 115 | result = { |
| 116 | "status": "ok", |
| 117 | "date": datetime.now().strftime("%Y-%m-%d"), |
| 118 | "since": since, |
| 119 | "topics": briefing_topics, |
| 120 | "total_new": total_new, |
| 121 | "total_topics": len(briefing_topics), |
| 122 | "top_finding": { |
| 123 | "title": top_overall.get("source_title", ""), |
| 124 | "topic": top_overall.get("_topic", ""), |
| 125 | "engagement": top_overall.get("engagement_score", 0), |
| 126 | } if top_overall else None, |
| 127 | "cost": { |
| 128 | "daily": daily_cost, |
| 129 | "budget": budget, |
| 130 | }, |
| 131 | "failed_topics": [ |
| 132 | t["name"] for t in briefing_topics if t["last_status"] == "failed" |
| 133 | ], |
| 134 | } |
| 135 | |
| 136 | # Save briefing data |
| 137 | _save_briefing(result) |
| 138 | |
| 139 | return result |
| 140 | |
| 141 | |
| 142 | def generate_weekly() -> dict: |
| 143 | """Generate weekly digest data with trend analysis.""" |
| 144 | store.init_db() |
| 145 | |
| 146 | week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") |
| 147 | two_weeks_ago = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d") |
| 148 | |
| 149 | topics = store.list_topics() |
| 150 | if not topics: |
| 151 | return {"status": "no_topics", "message": "No watchlist topics."} |
| 152 | |
| 153 | weekly_topics = [] |
| 154 | |
| 155 | for topic in topics: |
| 156 | if not topic["enabled"]: |
| 157 | continue |
| 158 | |
| 159 | # This week's findings |
| 160 | this_week = store.get_new_findings(topic["id"], week_ago) |
| 161 | |
| 162 | # Last week's findings (for comparison) |
| 163 | conn = store._connect() |
| 164 | try: |
| 165 | last_week_rows = conn.execute( |
| 166 | """SELECT * FROM findings |
| 167 | WHERE topic_id = ? AND first_seen >= ? AND first_seen < ? AND dismissed = 0 |
| 168 | ORDER BY engagement_score DESC""", |
| 169 | (topic["id"], two_weeks_ago, week_ago), |
| 170 | ).fetchall() |
| 171 | last_week = [dict(r) for r in last_week_rows] |
| 172 | finally: |
| 173 | conn.close() |
| 174 | |
| 175 | this_engagement = sum(f.get("engagement_score") or 0 for f in this_week) |
| 176 | last_engagement = sum(f.get("engagement_score") or 0 for f in last_week) |
| 177 | |
| 178 | # Trend calculation |
| 179 | if last_engagement > 0: |
| 180 | engagement_change = ((this_engagement - last_engagement) / last_engagement) * 100 |
| 181 | else: |
| 182 | engagement_change = 100 if this_engagement > 0 else 0 |
| 183 | |
| 184 | weekly_topics.append({ |
| 185 | "name": topic["name"], |
| 186 | "this_week_count": len(this_week), |
| 187 | "last_week_count": len(last_week), |
| 188 | "this_week_engagement": this_engagement, |
| 189 | "last_week_engagement": last_engagement, |
| 190 | "engagement_change_pct": round(engagement_change, 1), |
| 191 | # get_new_findings returns first_seen DESC, so sort by engagement |
| 192 | # before slicing — otherwise the digest headlines the most recent |
| 193 | # items, not the highest-engagement ones (the daily path keys on |
| 194 | # engagement too). |
| 195 | "top_findings": sorted( |
| 196 | this_week, |
| 197 | key=lambda f: f.get("engagement_score") or 0, |
| 198 | reverse=True, |
| 199 | )[:5], |
| 200 | }) |
| 201 | |
| 202 | result = { |
| 203 | "status": "ok", |
| 204 | "type": "weekly", |
| 205 | "week_of": week_ago, |
| 206 | "topics": weekly_topics, |
| 207 | } |
| 208 | |
| 209 | _save_briefing(result, suffix="-weekly") |
| 210 | |
| 211 | return result |
| 212 | |
| 213 | |
| 214 | def show_briefing(date: str = None) -> dict: |
| 215 | """Load a saved briefing by date.""" |
| 216 | if not date: |
| 217 | date = datetime.now().strftime("%Y-%m-%d") |
| 218 | |
| 219 | path = BRIEFS_DIR / f"{date}.json" |
| 220 | if not path.exists(): |
| 221 | # Try weekly |
| 222 | path = BRIEFS_DIR / f"{date}-weekly.json" |
| 223 | |
| 224 | if not path.exists(): |
| 225 | return {"status": "not_found", "message": f"No briefing found for {date}."} |
| 226 | |
| 227 | with open(path, encoding="utf-8") as f: |
| 228 | return json.load(f) |
| 229 | |
| 230 | |
| 231 | def _save_briefing(data: dict, suffix: str = ""): |
| 232 | """Save briefing data to local archive.""" |
| 233 | BRIEFS_DIR.mkdir(parents=True, exist_ok=True) |
| 234 | date = datetime.now().strftime("%Y-%m-%d") |
| 235 | path = BRIEFS_DIR / f"{date}{suffix}.json" |
| 236 | with open(path, "w", encoding="utf-8") as f: |
| 237 | json.dump(data, f, indent=2, default=str) |
| 238 | |
| 239 | |
| 240 | def main(): |
| 241 | parser = argparse.ArgumentParser(description="Generate last30days briefings") |
| 242 | sub = parser.add_subparsers(dest="command") |
| 243 | |
| 244 | # generate |
| 245 | g = sub.add_parser("generate", help="Generate a briefing") |
| 246 | g.add_argument("--weekly", action="store_true", help="Weekly digest") |
| 247 | g.add_argument("--since", help="Findings since date (YYYY-MM-DD)") |
| 248 | |
| 249 | # show |
| 250 | s = sub.add_parser("show", help="Show a saved briefing") |
| 251 | s.add_argument("--date", help="Date (YYYY-MM-DD, default: today)") |
| 252 | |
| 253 | args = parser.parse_args() |
| 254 | |
| 255 | if args.command == "generate": |
| 256 | if args.weekly: |
| 257 | result = generate_weekly() |
| 258 | else: |
| 259 | result = generate_daily(since=args.since) |
| 260 | print(json.dumps(result, indent=2, default=str)) |
| 261 | |
| 262 | elif args.command == "show": |
| 263 | result = show_briefing(date=args.date) |
| 264 | print(json.dumps(result, indent=2, default=str)) |
| 265 | |
| 266 | else: |
| 267 | parser.print_help() |
| 268 | sys.exit(1) |
| 269 | |
| 270 | |
| 271 | if __name__ == "__main__": |
| 272 | main() |
| 273 |