返回 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 "source": top_overall.get("source", ""),
125 "source_url": top_overall.get("source_url", ""),
126 "topic": top_overall.get("_topic", ""),
127 "engagement": top_overall.get("engagement_score", 0),
128 } if top_overall else None,
129 "cost": {
130 "daily": daily_cost,
131 "budget": budget,
132 },
133 "failed_topics": [
134 t["name"] for t in briefing_topics if t["last_status"] == "failed"
135 ],
136 }
137
138 # Save briefing data
139 _save_briefing(result)
140
141 return result
142
143
144 def generate_weekly() -> dict:
145 """Generate weekly digest data with trend analysis."""
146 store.init_db()
147
148 week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
149 two_weeks_ago = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
150
151 topics = store.list_topics()
152 if not topics:
153 return {"status": "no_topics", "message": "No watchlist topics."}
154
155 weekly_topics = []
156
157 for topic in topics:
158 if not topic["enabled"]:
159 continue
160
161 # This week's findings
162 this_week = store.get_new_findings(topic["id"], week_ago)
163
164 # Last week's findings (for comparison)
165 last_week = store.get_new_findings(topic["id"], two_weeks_ago, before=week_ago)
166
167 this_engagement = sum(f.get("engagement_score") or 0 for f in this_week)
168 last_engagement = sum(f.get("engagement_score") or 0 for f in last_week)
169
170 # Trend calculation
171 if last_engagement > 0:
172 engagement_change = ((this_engagement - last_engagement) / last_engagement) * 100
173 else:
174 engagement_change = 100 if this_engagement > 0 else 0
175
176 weekly_topics.append({
177 "name": topic["name"],
178 "this_week_count": len(this_week),
179 "last_week_count": len(last_week),
180 "this_week_engagement": this_engagement,
181 "last_week_engagement": last_engagement,
182 "engagement_change_pct": round(engagement_change, 1),
183 # get_new_findings returns first_seen DESC, so sort by engagement
184 # before slicing — otherwise the digest headlines the most recent
185 # items, not the highest-engagement ones (the daily path keys on
186 # engagement too).
187 "top_findings": sorted(
188 this_week,
189 key=lambda f: f.get("engagement_score") or 0,
190 reverse=True,
191 )[:5],
192 })
193
194 result = {
195 "status": "ok",
196 "type": "weekly",
197 "week_of": week_ago,
198 "topics": weekly_topics,
199 }
200
201 _save_briefing(result, suffix="-weekly")
202
203 return result
204
205
206 def show_briefing(date: str = None) -> dict:
207 """Load a saved briefing by date."""
208 if not date:
209 date = datetime.now().strftime("%Y-%m-%d")
210
211 path = BRIEFS_DIR / f"{date}.json"
212 if not path.exists():
213 # Try weekly
214 path = BRIEFS_DIR / f"{date}-weekly.json"
215
216 if not path.exists():
217 return {"status": "not_found", "message": f"No briefing found for {date}."}
218
219 with open(path, encoding="utf-8") as f:
220 return json.load(f)
221
222
223 def _save_briefing(data: dict, suffix: str = ""):
224 """Save briefing data to local archive."""
225 BRIEFS_DIR.mkdir(parents=True, exist_ok=True)
226 date = datetime.now().strftime("%Y-%m-%d")
227 path = BRIEFS_DIR / f"{date}{suffix}.json"
228 with open(path, "w", encoding="utf-8") as f:
229 json.dump(data, f, indent=2, default=str)
230
231
232 def main():
233 parser = argparse.ArgumentParser(description="Generate last30days briefings")
234 sub = parser.add_subparsers(dest="command")
235
236 # generate
237 g = sub.add_parser("generate", help="Generate a briefing")
238 g.add_argument("--weekly", action="store_true", help="Weekly digest")
239 g.add_argument("--since", help="Findings since date (YYYY-MM-DD)")
240
241 # show
242 s = sub.add_parser("show", help="Show a saved briefing")
243 s.add_argument("--date", help="Date (YYYY-MM-DD, default: today)")
244
245 args = parser.parse_args()
246
247 if args.command == "generate":
248 if args.weekly:
249 result = generate_weekly()
250 else:
251 result = generate_daily(since=args.since)
252 print(json.dumps(result, indent=2, default=str))
253
254 elif args.command == "show":
255 result = show_briefing(date=args.date)
256 print(json.dumps(result, indent=2, default=str))
257
258 else:
259 parser.print_help()
260 sys.exit(1)
261
262
263 if __name__ == "__main__":
264 main()
265
265 lines PYTHON