@jeganwrites/claudash 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/insights.py ADDED
@@ -0,0 +1,359 @@
1
+ """Insights engine — runs after every scan, generates actionable insights."""
2
+
3
+ import json
4
+ import time
5
+ from datetime import datetime, timezone, timedelta
6
+ from collections import defaultdict
7
+
8
+ from config import MODEL_PRICING, COST_TARGETS
9
+ from db import get_conn, insert_insight, get_insights, get_accounts_config, get_project_map_config
10
+ from analyzer import (
11
+ account_metrics, window_metrics, project_metrics,
12
+ compaction_metrics, model_rightsizing,
13
+ )
14
+
15
+
16
+ def _now():
17
+ return int(time.time())
18
+
19
+
20
+ def _days_ago(n):
21
+ return _now() - (n * 86400)
22
+
23
+
24
+ def _fetch_rows(conn, account=None, since=None):
25
+ sql = "SELECT * FROM sessions WHERE 1=1"
26
+ params = []
27
+ if account and account != "all":
28
+ sql += " AND account = ?"
29
+ params.append(account)
30
+ if since:
31
+ sql += " AND timestamp >= ?"
32
+ params.append(since)
33
+ return conn.execute(sql, params).fetchall()
34
+
35
+
36
+ def _clear_stale_insights(conn, max_age_hours=24):
37
+ cutoff = _now() - (max_age_hours * 3600)
38
+ conn.execute("DELETE FROM insights WHERE dismissed = 0 AND created_at < ?", (cutoff,))
39
+ conn.commit()
40
+
41
+
42
+ def _insight_exists_recent(conn, insight_type, project, hours=12):
43
+ cutoff = _now() - (hours * 3600)
44
+ row = conn.execute(
45
+ "SELECT COUNT(*) FROM insights WHERE insight_type = ? AND project = ? AND created_at > ? AND dismissed = 0",
46
+ (insight_type, project, cutoff),
47
+ ).fetchone()
48
+ return row[0] > 0
49
+
50
+
51
+ def generate_insights(conn=None):
52
+ """Run all insight rules. Returns count of new insights generated."""
53
+ should_close = False
54
+ if conn is None:
55
+ conn = get_conn()
56
+ should_close = True
57
+
58
+ ACCOUNTS = get_accounts_config(conn)
59
+ PROJECT_MAP = get_project_map_config(conn)
60
+
61
+ _clear_stale_insights(conn)
62
+ generated = 0
63
+
64
+ # ── 1. MODEL_WASTE ──
65
+ for acct_key in ACCOUNTS:
66
+ rs = model_rightsizing(conn, acct_key)
67
+ for s in rs:
68
+ if _insight_exists_recent(conn, "model_waste", s["project"]):
69
+ continue
70
+ msg = (
71
+ f"{s['project']} uses Opus but avg response is {s['avg_output_tokens']} tokens "
72
+ f"— Sonnet saves ~${s['monthly_savings']:.2f}/mo"
73
+ )
74
+ detail = json.dumps({"avg_output": s["avg_output_tokens"], "savings": s["monthly_savings"]})
75
+ insert_insight(conn, acct_key, s["project"], "model_waste", msg, detail)
76
+ generated += 1
77
+
78
+ # ── 2. CACHE_SPIKE ──
79
+ rows_7d = _fetch_rows(conn, since=_days_ago(7))
80
+ rows_24h = _fetch_rows(conn, since=_days_ago(1))
81
+
82
+ project_cache_7d = defaultdict(int)
83
+ for r in rows_7d:
84
+ project_cache_7d[r["project"]] += r["cache_creation_tokens"]
85
+
86
+ project_cache_24h = defaultdict(int)
87
+ for r in rows_24h:
88
+ project_cache_24h[r["project"]] += r["cache_creation_tokens"]
89
+
90
+ for p, cache_24h in project_cache_24h.items():
91
+ avg_daily = project_cache_7d.get(p, 0) / 7
92
+ if avg_daily > 0 and cache_24h > avg_daily * 3:
93
+ if _insight_exists_recent(conn, "cache_spike", p):
94
+ continue
95
+ ratio = round(cache_24h / avg_daily, 1)
96
+ acct = "personal_max"
97
+ for proj_name, info in PROJECT_MAP.items():
98
+ if proj_name == p:
99
+ acct = info["account"]
100
+ break
101
+ msg = f"{p} cache creation spiked {ratio}x — possible CLAUDE.md reload bug"
102
+ detail = json.dumps({"ratio": ratio, "cache_24h": cache_24h, "avg_daily": round(avg_daily)})
103
+ insert_insight(conn, acct, p, "cache_spike", msg, detail)
104
+ generated += 1
105
+
106
+ # ── 3. COMPACTION_GAP ──
107
+ for acct_key in ACCOUNTS:
108
+ comp = compaction_metrics(conn, acct_key)
109
+ if comp["sessions_needing_compact"] > 0:
110
+ if _insight_exists_recent(conn, "compaction_gap", acct_key):
111
+ continue
112
+ n = comp["sessions_needing_compact"]
113
+ msg = f"{n} sessions this week hit 80% context with no /compact — risk of context rot"
114
+ detail = json.dumps({"sessions_needing_compact": n})
115
+ insert_insight(conn, acct_key, acct_key, "compaction_gap", msg, detail)
116
+ generated += 1
117
+
118
+ # ── 4. COST_TARGET_HIT ──
119
+ projs = project_metrics(conn)
120
+ for pm in projs:
121
+ target = COST_TARGETS.get(pm["name"])
122
+ if target and pm["avg_cost_per_session"] <= target and pm["avg_cost_per_session"] > 0:
123
+ if _insight_exists_recent(conn, "cost_target", pm["name"]):
124
+ continue
125
+ msg = f"{pm['name']} hit ${target:.2f}/file target — avg ${pm['avg_cost_per_session']:.4f}/session"
126
+ detail = json.dumps({"target": target, "actual": pm["avg_cost_per_session"]})
127
+ insert_insight(conn, pm["account"], pm["name"], "cost_target", msg, detail)
128
+ generated += 1
129
+
130
+ # ── 5. WINDOW_RISK ──
131
+ for acct_key in ACCOUNTS:
132
+ wm = window_metrics(conn, acct_key)
133
+ if wm["minutes_to_limit"] is not None and wm["minutes_to_limit"] < 60:
134
+ if _insight_exists_recent(conn, "window_risk", acct_key):
135
+ continue
136
+ label = ACCOUNTS[acct_key]["label"]
137
+ pct = wm["window_pct"]
138
+ predicted = ""
139
+ if wm["predicted_limit_time"]:
140
+ predicted = datetime.fromtimestamp(
141
+ wm["predicted_limit_time"], tz=timezone.utc
142
+ ).strftime("%H:%M UTC")
143
+ msg = f"{label} window at {pct:.0f}% — exhaust predicted at {predicted}"
144
+ detail = json.dumps({"pct": pct, "minutes_left": wm["minutes_to_limit"]})
145
+ insert_insight(conn, acct_key, acct_key, "window_risk", msg, detail)
146
+ generated += 1
147
+
148
+ # ── 6. ROI_MILESTONE ──
149
+ for acct_key, acct_info in ACCOUNTS.items():
150
+ am = account_metrics(conn, acct_key)
151
+ roi = am.get("subscription_roi", 0)
152
+ for threshold in [10, 5, 2]:
153
+ if roi >= threshold:
154
+ milestone_key = f"roi_{threshold}x"
155
+ if _insight_exists_recent(conn, "roi_milestone", f"{acct_key}_{milestone_key}", hours=168):
156
+ break
157
+ label = acct_info["label"]
158
+ plan_cost = acct_info.get("monthly_cost_usd", 0)
159
+ api_equiv = round(roi * plan_cost, 0)
160
+ msg = f"{label} ROI crossed {threshold}x this month — ${api_equiv:.0f} API equiv on ${plan_cost} plan"
161
+ detail = json.dumps({"roi": roi, "threshold": threshold, "api_equiv": api_equiv})
162
+ insert_insight(conn, acct_key, f"{acct_key}_{milestone_key}", "roi_milestone", msg, detail)
163
+ generated += 1
164
+ break
165
+
166
+ # ── 7. HEAVY_DAY_PATTERN ──
167
+ for acct_key in ACCOUNTS:
168
+ rows_30d_acct = _fetch_rows(conn, acct_key, _days_ago(30))
169
+ day_sessions = defaultdict(lambda: defaultdict(int))
170
+ for r in rows_30d_acct:
171
+ dow = datetime.fromtimestamp(r["timestamp"], tz=timezone.utc).strftime("%A")
172
+ day_sessions[dow][r["project"]] += 1
173
+
174
+ if day_sessions:
175
+ heaviest = max(day_sessions.items(), key=lambda x: sum(x[1].values()))
176
+ day_name = heaviest[0]
177
+ total = sum(heaviest[1].values())
178
+ avg_day = sum(sum(v.values()) for v in day_sessions.values()) / len(day_sessions)
179
+ if total > avg_day * 1.5:
180
+ top_project = max(heaviest[1].items(), key=lambda x: x[1])[0]
181
+ if not _insight_exists_recent(conn, "heavy_day", f"{acct_key}_{day_name}", hours=168):
182
+ label = ACCOUNTS[acct_key]["label"]
183
+ msg = f"{day_name}s are your heaviest Claude day — {top_project} pattern"
184
+ detail = json.dumps({"day": day_name, "sessions": total, "top_project": top_project})
185
+ insert_insight(conn, acct_key, f"{acct_key}_{day_name}", "heavy_day", msg, detail)
186
+ generated += 1
187
+
188
+ # ── 8. BEST_WINDOW ──
189
+ for acct_key in ACCOUNTS:
190
+ rows_7d_acct = _fetch_rows(conn, acct_key, _days_ago(7))
191
+ hour_tokens = defaultdict(int)
192
+ for r in rows_7d_acct:
193
+ h = datetime.fromtimestamp(r["timestamp"], tz=timezone.utc).hour
194
+ hour_tokens[h] += r["input_tokens"] + r["output_tokens"]
195
+
196
+ if hour_tokens:
197
+ best_start = 0
198
+ min_usage = float("inf")
199
+ for start_h in range(24):
200
+ block = sum(hour_tokens.get((start_h + i) % 24, 0) for i in range(5))
201
+ if block < min_usage:
202
+ min_usage = block
203
+ best_start = start_h
204
+
205
+ if not _insight_exists_recent(conn, "best_window", acct_key, hours=168):
206
+ end_h = (best_start + 5) % 24
207
+ msg = f"Your quietest window is {best_start}:00-{end_h}:00 UTC — ideal for autonomous runs"
208
+ detail = json.dumps({"start_hour": best_start, "end_hour": end_h, "tokens_in_block": min_usage})
209
+ insert_insight(conn, acct_key, acct_key, "best_window", msg, detail)
210
+ generated += 1
211
+
212
+ # ── 9. WINDOW_COMBINED_RISK (Code + Browser combined > 80%) ──
213
+ try:
214
+ from db import get_claude_ai_accounts_all, get_latest_claude_ai_snapshot
215
+ browser_accts = get_claude_ai_accounts_all(conn)
216
+ for ba in browser_accts:
217
+ aid = ba["account_id"]
218
+ if ba.get("status") != "active":
219
+ continue
220
+ snap = get_latest_claude_ai_snapshot(conn, aid)
221
+ if not snap:
222
+ continue
223
+ # Get Code window pct
224
+ code_wm = window_metrics(conn, aid)
225
+ code_pct = code_wm.get("window_pct", 0)
226
+ browser_pct = snap.get("pct_used", 0)
227
+ acct_info = ACCOUNTS.get(aid, {})
228
+ limit = acct_info.get("window_token_limit", 1_000_000)
229
+ # Combined estimate (both eat from same window)
230
+ combined_pct = code_pct + browser_pct
231
+ if combined_pct > 80:
232
+ if not _insight_exists_recent(conn, "window_combined_risk", aid):
233
+ label = acct_info.get("label", aid)
234
+ msg = f"Combined window (Code + browser) at {combined_pct:.0f}% for {label} — slow down"
235
+ detail = json.dumps({"code_pct": code_pct, "browser_pct": browser_pct, "combined": combined_pct})
236
+ insert_insight(conn, aid, aid, "window_combined_risk", msg, detail)
237
+ generated += 1
238
+ except Exception:
239
+ pass
240
+
241
+ # ── 10. SESSION_EXPIRY_WARNING ──
242
+ try:
243
+ for ba in browser_accts:
244
+ aid = ba["account_id"]
245
+ if ba.get("status") == "expired":
246
+ last_polled = ba.get("last_polled", 0) or 0
247
+ if _now() - last_polled > 1800: # > 30 min stale
248
+ if not _insight_exists_recent(conn, "session_expiry", aid):
249
+ label = ACCOUNTS.get(aid, {}).get("label", aid)
250
+ msg = f"{label} claude.ai session expired — update key in Accounts"
251
+ insert_insight(conn, aid, aid, "session_expiry", msg, "{}")
252
+ generated += 1
253
+ except Exception:
254
+ pass
255
+
256
+ # ── 11. PRO_MESSAGES_LOW ──
257
+ try:
258
+ for ba in browser_accts:
259
+ aid = ba["account_id"]
260
+ if ba.get("status") != "active" or ba.get("plan") != "pro":
261
+ continue
262
+ snap = get_latest_claude_ai_snapshot(conn, aid)
263
+ if not snap:
264
+ continue
265
+ msgs_used = snap.get("messages_used", 0)
266
+ msgs_limit = snap.get("messages_limit", 0)
267
+ if msgs_limit > 0 and msgs_used / msgs_limit > 0.7:
268
+ if not _insight_exists_recent(conn, "pro_messages_low", aid):
269
+ label = ACCOUNTS.get(aid, {}).get("label", aid)
270
+ msg = f"{label} at {msgs_used}/{msgs_limit} messages — consider spacing out conversations"
271
+ detail = json.dumps({"used": msgs_used, "limit": msgs_limit})
272
+ insert_insight(conn, aid, aid, "pro_messages_low", msg, detail)
273
+ generated += 1
274
+ except Exception:
275
+ pass
276
+
277
+ # ── 12. SUBAGENT_COST_SPIKE ──
278
+ try:
279
+ from analyzer import subagent_metrics as _subagent_metrics
280
+ sm = _subagent_metrics(conn, "all")
281
+ for proj, s in sm.items():
282
+ if s["subagent_pct_of_total"] > 30 and s["subagent_session_count"] > 0:
283
+ if _insight_exists_recent(conn, "subagent_cost_spike", proj):
284
+ continue
285
+ pct = s["subagent_pct_of_total"]
286
+ cost = s["subagent_cost_usd"]
287
+ msg = (f"{proj} sub-agents consumed {pct:.0f}% of project cost "
288
+ f"(${cost:.2f}) — check if orchestration is efficient")
289
+ detail = json.dumps({"pct": pct, "cost": cost,
290
+ "sessions": s["subagent_session_count"]})
291
+ # Pick any account for this project (first match)
292
+ acct_row = conn.execute(
293
+ "SELECT account FROM sessions WHERE project=? LIMIT 1", (proj,)
294
+ ).fetchone()
295
+ acct_key = acct_row["account"] if acct_row else "all"
296
+ insert_insight(conn, acct_key, proj, "subagent_cost_spike", msg, detail)
297
+ generated += 1
298
+ except Exception:
299
+ pass
300
+
301
+ # ── 13. FLOUNDERING_DETECTED ──
302
+ try:
303
+ floundering_rows = conn.execute(
304
+ "SELECT project, account, COUNT(*) AS n, SUM(token_cost) AS wasted "
305
+ "FROM waste_events WHERE pattern_type='floundering' "
306
+ " AND detected_at >= ? "
307
+ "GROUP BY project, account",
308
+ (_days_ago(7),),
309
+ ).fetchall()
310
+ for r in floundering_rows:
311
+ proj = r["project"] or "Other"
312
+ if _insight_exists_recent(conn, "floundering_detected", proj):
313
+ continue
314
+ n = r["n"] or 0
315
+ wasted = r["wasted"] or 0
316
+ msg = (f"{proj} has {n} floundering session{'s' if n != 1 else ''} — "
317
+ f"Claude stuck retrying the same tool (~${wasted:.2f} at risk)")
318
+ detail = json.dumps({"count": n, "estimated_waste_usd": round(wasted, 4)})
319
+ insert_insight(conn, r["account"] or "all", proj,
320
+ "floundering_detected", msg, detail)
321
+ generated += 1
322
+ except Exception:
323
+ pass
324
+
325
+ # ── 14. DAILY_BUDGET alerts (exceeded + warning) ──
326
+ try:
327
+ from analyzer import daily_budget_metrics as _daily_budget_metrics
328
+ dbm = _daily_budget_metrics(conn, "all")
329
+ for acct_id, b in dbm.items():
330
+ if not b.get("has_budget"):
331
+ continue
332
+ label = ACCOUNTS.get(acct_id, {}).get("label", acct_id)
333
+ cost = b["today_cost"]
334
+ limit = b["budget_usd"]
335
+ pct = b["budget_pct"]
336
+ if cost > limit:
337
+ if not _insight_exists_recent(conn, "budget_exceeded", acct_id, hours=6):
338
+ over = cost - limit
339
+ msg = (f"{label} exceeded daily budget — ${cost:.2f} spent vs "
340
+ f"${limit:.2f} limit (${over:.2f} over). Slow down or switch to Sonnet.")
341
+ detail = json.dumps({"today_cost": cost, "budget": limit, "over": over})
342
+ insert_insight(conn, acct_id, acct_id, "budget_exceeded", msg, detail)
343
+ generated += 1
344
+ elif pct > 80:
345
+ if not _insight_exists_recent(conn, "budget_warning", acct_id, hours=6):
346
+ proj_daily = b["projected_daily"]
347
+ msg = (f"{label} at {pct:.0f}% of daily budget — projected "
348
+ f"${proj_daily:.2f} vs ${limit:.2f} limit")
349
+ detail = json.dumps({"pct": pct, "projected": proj_daily, "budget": limit})
350
+ insert_insight(conn, acct_id, acct_id, "budget_warning", msg, detail)
351
+ generated += 1
352
+ except Exception:
353
+ pass
354
+
355
+ conn.commit()
356
+ if should_close:
357
+ conn.close()
358
+
359
+ return generated