返回 last30days-skill
briefing.py
根目录 / skills / last30days / scripts / briefing.py
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
273 lines PYTHON