@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/fix_tracker.py ADDED
@@ -0,0 +1,539 @@
1
+ """Fix Tracker — record a fix, measure whether it worked.
2
+
3
+ Workflow:
4
+ 1. User records a fix for a project + waste pattern via `POST /api/fixes`
5
+ or `cli.py fix add`. Claudash snapshots the project's current metrics
6
+ into `fixes.baseline_json`.
7
+ 2. User applies the fix to their CLAUDE.md / settings.json / prompts /
8
+ architecture and uses Claude Code normally for a week.
9
+ 3. User runs `POST /api/fixes/{id}/measure` (or `cli.py measure {id}`).
10
+ Claudash captures a fresh snapshot, diffs it against the baseline,
11
+ assigns a plan-aware verdict, and stores the measurement.
12
+ 4. The dashboard shows the before/after delta; a share-card endpoint
13
+ produces a plain-text receipt the user can paste anywhere.
14
+
15
+ Plan-aware framing — the module's most important contract:
16
+
17
+ * Max / Pro (flat subscription):
18
+ Primary metric = **window efficiency** (useful tokens / total tokens).
19
+ Savings are reported as "API-equivalent waste eliminated", never as
20
+ "you saved $X". The story is "same $100/mo plan, 2.5× more output".
21
+ * API (pay-per-token):
22
+ Primary metric = **cost_usd**. Savings are real dollars. The story
23
+ is "spent $X less this month".
24
+
25
+ This module never conflates the two. The verdict logic, the delta JSON,
26
+ the share card, and the CLI output all branch on `plan_type`.
27
+ """
28
+
29
+ import json
30
+ import os
31
+ import sqlite3
32
+ import time
33
+ from collections import defaultdict
34
+
35
+ from db import (
36
+ get_conn, get_accounts_config, insert_fix, get_fix, get_all_fixes,
37
+ update_fix_status, insert_fix_measurement, get_fix_measurements,
38
+ get_latest_fix_measurement,
39
+ )
40
+
41
+
42
+ # ─── Constants ───────────────────────────────────────────────────
43
+
44
+ WASTE_PATTERNS = [
45
+ "floundering",
46
+ "repeated_reads",
47
+ "deep_no_compact",
48
+ "cost_outlier",
49
+ "cache_spike",
50
+ "model_waste",
51
+ "compaction_late",
52
+ "custom",
53
+ ]
54
+
55
+ FIX_TYPES = [
56
+ "claude_md",
57
+ "settings_json",
58
+ "prompt",
59
+ "architecture",
60
+ "other",
61
+ ]
62
+
63
+ WASTE_PATTERN_LABELS = {
64
+ "floundering": "Floundering (tool retry loops)",
65
+ "repeated_reads": "Repeated file reads",
66
+ "deep_no_compact": "Deep session without compaction",
67
+ "cost_outlier": "Cost outlier session",
68
+ "cache_spike": "Cache creation spike",
69
+ "model_waste": "Model waste (Opus for small outputs)",
70
+ "compaction_late": "Compaction fired too late",
71
+ "custom": "Custom pattern",
72
+ }
73
+
74
+ # Plan-aware thresholds. Applied to the delta computation in
75
+ # `determine_verdict` — see the body of that function for how they're used.
76
+ MIN_SESSIONS_FOR_VERDICT = 3
77
+ WASTE_IMPROVING_PCT = 20
78
+ WASTE_WORSENED_PCT = 10
79
+ WINDOW_IMPROVING_PCT = 15
80
+ WINDOW_WORSENED_PCT = 10
81
+ COST_IMPROVING_PCT = 10
82
+ COST_WORSENED_PCT = 10
83
+ CONFIRM_MIN_DAYS = 7
84
+
85
+
86
+ # ─── Plan lookup ─────────────────────────────────────────────────
87
+
88
+ def get_project_plan_info(conn, project):
89
+ """Return (account_id, plan_type, monthly_cost_usd) for a project.
90
+
91
+ We look up the first session row for the project to find its account,
92
+ then read plan + monthly cost from the accounts table. Returns
93
+ ('all', 'max', 0.0) if the project has no data yet (new install path).
94
+ """
95
+ row = conn.execute(
96
+ "SELECT account FROM sessions WHERE project = ? LIMIT 1",
97
+ (project,),
98
+ ).fetchone()
99
+ acct_id = (row["account"] if row else None) or "all"
100
+ accounts = get_accounts_config(conn)
101
+ info = accounts.get(acct_id) or {}
102
+ plan = info.get("plan") or "max"
103
+ cost = float(info.get("monthly_cost_usd") or 0)
104
+ return acct_id, plan, cost
105
+
106
+
107
+ # ─── Baseline capture ────────────────────────────────────────────
108
+
109
+ def capture_baseline(conn, project, days_window=7):
110
+ """Snapshot the project's key metrics right now. Safe to call repeatedly —
111
+ the snapshot is self-contained and travels inside `fixes.baseline_json`."""
112
+ acct_id, plan, plan_cost = get_project_plan_info(conn, project)
113
+ since = int(time.time()) - (days_window * 86400)
114
+
115
+ # Aggregate session rows for this project in the window
116
+ agg = conn.execute(
117
+ """SELECT
118
+ COUNT(DISTINCT session_id) AS sessions,
119
+ COALESCE(SUM(cost_usd), 0) AS total_cost,
120
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read,
121
+ COALESCE(SUM(cache_creation_tokens), 0) AS cache_create,
122
+ COALESCE(SUM(input_tokens), 0) AS input_tok,
123
+ COALESCE(SUM(output_tokens), 0) AS output_tok,
124
+ COUNT(*) AS total_rows,
125
+ COALESCE(SUM(CASE WHEN compaction_detected=1 THEN 1 ELSE 0 END), 0) AS compactions,
126
+ COALESCE(SUM(CASE WHEN is_subagent=1 THEN cost_usd ELSE 0 END), 0) AS subagent_cost
127
+ FROM sessions
128
+ WHERE project = ? AND timestamp >= ?""",
129
+ (project, since),
130
+ ).fetchone()
131
+
132
+ sessions_count = agg["sessions"] or 0
133
+ total_cost = float(agg["total_cost"] or 0)
134
+ cache_read = int(agg["cache_read"] or 0)
135
+ cache_create = int(agg["cache_create"] or 0)
136
+ input_tok = int(agg["input_tok"] or 0)
137
+ output_tok = int(agg["output_tok"] or 0)
138
+ total_rows = int(agg["total_rows"] or 0)
139
+ compactions = int(agg["compactions"] or 0)
140
+ subagent_cost = float(agg["subagent_cost"] or 0)
141
+
142
+ avg_cost_per_session = (total_cost / sessions_count) if sessions_count > 0 else 0.0
143
+
144
+ total_cache_activity = cache_read + cache_create
145
+ cache_hit_rate = (cache_read / total_cache_activity * 100) if total_cache_activity > 0 else 0.0
146
+
147
+ total_tokens = input_tok + cache_read # inbound context size
148
+ avg_tokens_per_turn = (total_tokens / total_rows) if total_rows > 0 else 0.0
149
+ avg_cache_read_per_turn = (cache_read / total_rows) if total_rows > 0 else 0.0
150
+
151
+ avg_turns_per_session = (total_rows / sessions_count) if sessions_count > 0 else 0.0
152
+ compaction_rate = (compactions / sessions_count) if sessions_count > 0 else 0.0
153
+
154
+ # Waste events within the same window
155
+ waste_rows = conn.execute(
156
+ """SELECT pattern_type, COUNT(*) AS n
157
+ FROM waste_events
158
+ WHERE project = ? AND detected_at >= ?
159
+ GROUP BY pattern_type""",
160
+ (project, since),
161
+ ).fetchall()
162
+ waste = {
163
+ "floundering": 0,
164
+ "repeated_reads": 0,
165
+ "deep_no_compact": 0,
166
+ "cost_outliers": 0,
167
+ "total": 0,
168
+ }
169
+ for r in waste_rows:
170
+ pt = r["pattern_type"]
171
+ n = r["n"] or 0
172
+ if pt == "floundering":
173
+ waste["floundering"] = n
174
+ elif pt == "repeated_reads":
175
+ waste["repeated_reads"] = n
176
+ elif pt == "deep_no_compact":
177
+ waste["deep_no_compact"] = n
178
+ elif pt == "cost_outlier":
179
+ waste["cost_outliers"] = n
180
+ waste["total"] += n
181
+
182
+ # Token waste attribution — conservative per-turn scaling.
183
+ #
184
+ # Each floundering event represents ~1 wasted turn (Claude retried the
185
+ # same tool without progress). Each repeated_read event represents
186
+ # ~2 extra `Read` calls beyond the first necessary one. Both are scaled
187
+ # by per-turn token averages, NOT per-session. Scaling by per-session
188
+ # values wildly over-counts under prompt caching (cache_read dwarfs
189
+ # everything else) and makes effective_window_pct collapse to 0.
190
+ REPEATED_READ_EXTRA_TURNS = 2
191
+ tokens_wasted_on_floundering = int(waste["floundering"] * avg_tokens_per_turn)
192
+ tokens_wasted_on_repeated_reads = int(
193
+ waste["repeated_reads"] * REPEATED_READ_EXTRA_TURNS * avg_cache_read_per_turn
194
+ )
195
+ wasted_total = tokens_wasted_on_floundering + tokens_wasted_on_repeated_reads
196
+
197
+ if total_tokens > 0:
198
+ effective_window_pct = max(0.0, (total_tokens - wasted_total) / total_tokens * 100)
199
+ else:
200
+ effective_window_pct = 0.0
201
+
202
+ # Sub-agent cost share
203
+ subagent_cost_pct = (subagent_cost / total_cost * 100) if total_cost > 0 else 0.0
204
+
205
+ # Window burn → files_per_window
206
+ # We use window_burns if populated for this account, otherwise derive
207
+ # sessions-per-5-hour-block from session timestamps.
208
+ fpw = _estimate_files_per_window(conn, project, acct_id, since)
209
+
210
+ # Window hit rate (0..1) — fraction of rolled-up windows that hit 100%
211
+ hit = conn.execute(
212
+ "SELECT COALESCE(AVG(CASE WHEN hit_limit=1 THEN 1.0 ELSE 0.0 END), 0) "
213
+ "FROM window_burns WHERE account = ?",
214
+ (acct_id,),
215
+ ).fetchone()
216
+ window_hit_rate = float((hit[0] or 0)) if hit else 0.0
217
+
218
+ baseline = {
219
+ "project": project,
220
+ "captured_at": int(time.time()),
221
+ "days_window": days_window,
222
+ "plan_type": plan,
223
+ "plan_cost_usd": plan_cost,
224
+ "sessions_count": sessions_count,
225
+ "cost_usd": round(total_cost, 4),
226
+ "avg_cost_per_session": round(avg_cost_per_session, 4),
227
+ "cache_hit_rate": round(cache_hit_rate, 2),
228
+ "avg_turns_per_session": round(avg_turns_per_session, 1),
229
+ "compaction_events": compactions,
230
+ "compaction_rate": round(compaction_rate, 2),
231
+ "waste_events": waste,
232
+ "subagent_cost_pct": round(subagent_cost_pct, 1),
233
+ "window_hit_rate": round(window_hit_rate, 3),
234
+ "tokens_wasted_on_floundering": tokens_wasted_on_floundering,
235
+ "tokens_wasted_on_repeated_reads": tokens_wasted_on_repeated_reads,
236
+ "effective_window_pct": round(effective_window_pct, 2),
237
+ "files_per_window": fpw,
238
+ # Scratch fields used by compute_delta
239
+ "_total_tokens": total_tokens,
240
+ "_avg_tokens_per_turn": round(avg_tokens_per_turn, 2),
241
+ "_avg_cache_read_per_turn": round(avg_cache_read_per_turn, 2),
242
+ }
243
+ return baseline
244
+
245
+
246
+ def _estimate_files_per_window(conn, project, acct_id, since):
247
+ """Approximate 'sessions completed per 5-hour window' for this project.
248
+
249
+ Implementation: count distinct session_ids, bucket their first-seen
250
+ timestamps into 5-hour buckets, then average. Returns an integer.
251
+ """
252
+ rows = conn.execute(
253
+ """SELECT session_id, MIN(timestamp) AS first_seen
254
+ FROM sessions
255
+ WHERE project = ? AND timestamp >= ?
256
+ GROUP BY session_id""",
257
+ (project, since),
258
+ ).fetchall()
259
+ if not rows:
260
+ return 0
261
+ buckets = defaultdict(int)
262
+ for r in rows:
263
+ ts = r["first_seen"] or 0
264
+ buckets[ts // 18000] += 1
265
+ if not buckets:
266
+ return 0
267
+ return int(round(sum(buckets.values()) / len(buckets)))
268
+
269
+
270
+ # ─── Delta computation + verdict ─────────────────────────────────
271
+
272
+ def _pct_change(before, after):
273
+ """Signed percent change. Returns 0 when `before` is 0 to avoid inf."""
274
+ if before == 0:
275
+ return 0.0
276
+ return round(((after - before) / before) * 100.0, 1)
277
+
278
+
279
+ def compute_delta(conn, fix_id):
280
+ """Capture current metrics for the fixed project, diff against baseline,
281
+ assign a verdict, return (delta_json, verdict, current_metrics)."""
282
+ fix = get_fix(conn, fix_id)
283
+ if not fix:
284
+ return None, "not_found", None
285
+ baseline = json.loads(fix["baseline_json"] or "{}")
286
+ project = fix["project"]
287
+ plan_type = baseline.get("plan_type", "max")
288
+ plan_cost = baseline.get("plan_cost_usd", 0)
289
+
290
+ current = capture_baseline(conn, project, days_window=baseline.get("days_window", 7))
291
+
292
+ # Sessions since the fix was recorded (ANY row belonging to a session_id
293
+ # with a timestamp after fix.created_at)
294
+ sessions_since = conn.execute(
295
+ """SELECT COUNT(DISTINCT session_id) FROM sessions
296
+ WHERE project = ? AND timestamp > ?""",
297
+ (project, fix["created_at"] or 0),
298
+ ).fetchone()[0] or 0
299
+
300
+ now = int(time.time())
301
+ days_elapsed = max(int((now - (fix["created_at"] or now)) / 86400), 0)
302
+
303
+ before_waste = baseline.get("waste_events", {}) or {}
304
+ after_waste = current.get("waste_events", {}) or {}
305
+
306
+ def _waste_delta(key):
307
+ b = before_waste.get(key, 0) or 0
308
+ a = after_waste.get(key, 0) or 0
309
+ return {"before": b, "after": a, "pct_change": _pct_change(b, a)}
310
+
311
+ total_before = before_waste.get("total", 0) or 0
312
+ total_after = after_waste.get("total", 0) or 0
313
+
314
+ before_eff = baseline.get("effective_window_pct", 0) or 0
315
+ after_eff = current.get("effective_window_pct", 0) or 0
316
+
317
+ before_fpw = baseline.get("files_per_window", 0) or 0
318
+ after_fpw = current.get("files_per_window", 0) or 0
319
+
320
+ before_cps = baseline.get("avg_cost_per_session", 0) or 0
321
+ after_cps = current.get("avg_cost_per_session", 0) or 0
322
+
323
+ before_total_cost = baseline.get("cost_usd", 0) or 0
324
+ after_total_cost = current.get("cost_usd", 0) or 0
325
+
326
+ # Token savings are the reduction in attributed waste tokens
327
+ tokens_saved = max(
328
+ (baseline.get("tokens_wasted_on_floundering", 0)
329
+ + baseline.get("tokens_wasted_on_repeated_reads", 0))
330
+ - (current.get("tokens_wasted_on_floundering", 0)
331
+ + current.get("tokens_wasted_on_repeated_reads", 0)),
332
+ 0,
333
+ )
334
+
335
+ # API-equivalent monthly savings — always computed, reported as a
336
+ # distinct field, never framed as real dollars for flat-plan users.
337
+ before_month = before_total_cost / max(baseline.get("days_window", 7), 1) * 30.0
338
+ after_month = after_total_cost / max(current.get("days_window", 7), 1) * 30.0
339
+ api_equivalent_savings_monthly = round(max(before_month - after_month, 0), 2)
340
+
341
+ # Output multiplier
342
+ if before_fpw > 0 and after_fpw > 0:
343
+ improvement_multiplier = round(after_fpw / before_fpw, 2)
344
+ else:
345
+ improvement_multiplier = 1.0
346
+
347
+ primary_metric = "window_efficiency" if plan_type in ("max", "pro") else "cost_usd"
348
+
349
+ delta = {
350
+ "plan_type": plan_type,
351
+ "plan_cost_usd": plan_cost,
352
+ "primary_metric": primary_metric,
353
+ "days_elapsed": days_elapsed,
354
+ "sessions_since_fix": sessions_since,
355
+ "waste_events": {"before": total_before, "after": total_after,
356
+ "pct_change": _pct_change(total_before, total_after)},
357
+ "floundering": _waste_delta("floundering"),
358
+ "repeated_reads": _waste_delta("repeated_reads"),
359
+ "deep_no_compact": _waste_delta("deep_no_compact"),
360
+ "cost_outliers": _waste_delta("cost_outliers"),
361
+ "effective_window_pct": {"before": round(before_eff, 1),
362
+ "after": round(after_eff, 1),
363
+ "pct_change": _pct_change(before_eff, after_eff)},
364
+ "tokens_saved": tokens_saved,
365
+ "files_per_window": {"before": before_fpw, "after": after_fpw,
366
+ "pct_change": _pct_change(before_fpw, after_fpw)},
367
+ "avg_cost_per_session": {"before": round(before_cps, 4),
368
+ "after": round(after_cps, 4),
369
+ "pct_change": _pct_change(before_cps, after_cps)},
370
+ "cost_usd": {"before": round(before_total_cost, 2),
371
+ "after": round(after_total_cost, 2),
372
+ "pct_change": _pct_change(before_total_cost, after_total_cost)},
373
+ "avg_turns_per_session": {"before": baseline.get("avg_turns_per_session", 0),
374
+ "after": current.get("avg_turns_per_session", 0),
375
+ "pct_change": _pct_change(
376
+ baseline.get("avg_turns_per_session", 0),
377
+ current.get("avg_turns_per_session", 0))},
378
+ "api_equivalent_savings_monthly": api_equivalent_savings_monthly,
379
+ "improvement_multiplier": improvement_multiplier,
380
+ }
381
+
382
+ verdict = determine_verdict(delta, plan_type, sessions_since)
383
+ return delta, verdict, current
384
+
385
+
386
+ def determine_verdict(delta, plan_type, sessions_since):
387
+ """Return 'improving' | 'worsened' | 'neutral' | 'insufficient_data'.
388
+
389
+ Plan-aware: max/pro use window efficiency as the primary signal; api
390
+ uses raw cost. Waste events are always a valid trigger in both modes
391
+ because reducing waste should help either metric.
392
+ """
393
+ if sessions_since < MIN_SESSIONS_FOR_VERDICT:
394
+ return "insufficient_data"
395
+
396
+ waste_pct = delta["waste_events"]["pct_change"]
397
+ if waste_pct <= -WASTE_IMPROVING_PCT:
398
+ return "improving"
399
+ if waste_pct >= WASTE_WORSENED_PCT:
400
+ return "worsened"
401
+
402
+ if plan_type in ("max", "pro"):
403
+ eff_pct = delta["effective_window_pct"]["pct_change"]
404
+ if eff_pct >= WINDOW_IMPROVING_PCT:
405
+ return "improving"
406
+ if eff_pct <= -WINDOW_WORSENED_PCT:
407
+ return "worsened"
408
+ else: # api
409
+ cost_pct = delta["cost_usd"]["pct_change"]
410
+ if cost_pct <= -COST_IMPROVING_PCT:
411
+ return "improving"
412
+ if cost_pct >= COST_WORSENED_PCT:
413
+ return "worsened"
414
+
415
+ return "neutral"
416
+
417
+
418
+ def record_fix(conn, project, waste_pattern, title, fix_type, fix_detail):
419
+ """Capture a baseline and persist a new fix row. Return (fix_id, baseline)."""
420
+ if waste_pattern not in WASTE_PATTERNS:
421
+ waste_pattern = "custom"
422
+ if fix_type not in FIX_TYPES:
423
+ fix_type = "other"
424
+ baseline = capture_baseline(conn, project)
425
+ fix_id = insert_fix(conn, project, waste_pattern, title, fix_type, fix_detail, baseline)
426
+ return fix_id, baseline
427
+
428
+
429
+ def measure_fix(conn, fix_id):
430
+ """Run a measurement for a fix and persist it. Returns
431
+ (delta_json, verdict, metrics) or (None, 'not_found', None)."""
432
+ delta, verdict, metrics = compute_delta(conn, fix_id)
433
+ if delta is None:
434
+ return None, verdict, None
435
+ insert_fix_measurement(conn, fix_id, metrics, delta, verdict)
436
+ # Promote to 'confirmed' once we have a durable improvement
437
+ if verdict == "improving" and delta.get("days_elapsed", 0) >= CONFIRM_MIN_DAYS:
438
+ update_fix_status(conn, fix_id, "confirmed")
439
+ elif verdict == "worsened":
440
+ update_fix_status(conn, fix_id, "applied") # keep 'applied' on regression
441
+ else:
442
+ update_fix_status(conn, fix_id, "measuring")
443
+ return delta, verdict, metrics
444
+
445
+
446
+ # ─── Share card ──────────────────────────────────────────────────
447
+
448
+ def build_share_card(fix, latest_measurement):
449
+ """Return a plain-text share card, plan-aware. See module docstring for
450
+ the framing rules."""
451
+ baseline = json.loads(fix["baseline_json"] or "{}")
452
+ plan_type = baseline.get("plan_type", "max")
453
+ plan_cost = baseline.get("plan_cost_usd", 0)
454
+ project = fix["project"]
455
+ pattern = fix["waste_pattern"] or ""
456
+ pattern_label = WASTE_PATTERN_LABELS.get(pattern, pattern.replace("_", " "))
457
+ title = fix["title"] or "(no title)"
458
+ border = "─" * 46
459
+
460
+ lines = [border, "Fixed a Claude Code waste pattern with Claudash", ""]
461
+ lines.append(f"Project: {project}")
462
+ lines.append(f"Issue: {pattern_label}")
463
+ lines.append(f"Fix: {title}")
464
+ lines.append("")
465
+
466
+ if not latest_measurement:
467
+ lines.append("(No measurements yet — run `cli.py measure` after ≥7 days.)")
468
+ lines.append("")
469
+ lines.append("Detected by Claudash — github.com/yourusername/claudash")
470
+ lines.append(border)
471
+ return "\n".join(lines)
472
+
473
+ delta = json.loads(latest_measurement.get("delta_json") or "{}")
474
+ days = delta.get("days_elapsed", 0)
475
+ waste = delta.get("waste_events", {})
476
+ waste_before = waste.get("before", 0)
477
+ waste_after = waste.get("after", 0)
478
+ waste_pct = waste.get("pct_change", 0)
479
+
480
+ lines.append(f"Before → After ({days} days):")
481
+ lines.append(f"• {pattern_label} events: {waste_before} → {waste_after} ({_signed(waste_pct)}%)")
482
+
483
+ if plan_type in ("max", "pro"):
484
+ eff = delta.get("effective_window_pct", {})
485
+ fpw = delta.get("files_per_window", {})
486
+ lines.append(f"• Window efficiency: {eff.get('before', 0)}% → {eff.get('after', 0)}% useful tokens")
487
+ lines.append(f"• Output per window: {fpw.get('before', 0)} → {fpw.get('after', 0)} files ({_signed(fpw.get('pct_change', 0))}%)")
488
+ lines.append("")
489
+ mult = delta.get("improvement_multiplier", 1.0)
490
+ api_eq = delta.get("api_equivalent_savings_monthly", 0)
491
+ lines.append(f"Same ${plan_cost:.0f}/mo plan. {mult}× more output.")
492
+ lines.append(f"API-equivalent waste eliminated: ~${api_eq:.0f}/mo")
493
+ else: # api
494
+ cps = delta.get("avg_cost_per_session", {})
495
+ monthly = delta.get("api_equivalent_savings_monthly", 0)
496
+ lines.append(f"• Cost per session: ${cps.get('before', 0):.2f} → ${cps.get('after', 0):.2f} ({_signed(cps.get('pct_change', 0))}%)")
497
+ lines.append(f"• Monthly savings: ~${monthly:.0f}/mo")
498
+
499
+ lines.append("")
500
+ lines.append("Detected by Claudash — github.com/yourusername/claudash")
501
+ lines.append(border)
502
+ return "\n".join(lines)
503
+
504
+
505
+ def _signed(pct):
506
+ """Format a percent change with an explicit sign."""
507
+ return f"+{pct:.0f}" if pct > 0 else f"{pct:.0f}"
508
+
509
+
510
+ # ─── Aggregation helpers used by API/CLI/UI ──────────────────────
511
+
512
+ def fix_with_latest(conn, fix_id):
513
+ """Return a fix dict with baseline parsed, measurements list, and
514
+ a top-level `latest` measurement shortcut."""
515
+ fix = get_fix(conn, fix_id)
516
+ if not fix:
517
+ return None
518
+ fix["baseline"] = json.loads(fix.pop("baseline_json") or "{}")
519
+ measurements = get_fix_measurements(conn, fix_id)
520
+ for m in measurements:
521
+ m["metrics"] = json.loads(m.pop("metrics_json") or "{}")
522
+ m["delta"] = json.loads(m.pop("delta_json") or "{}")
523
+ fix["measurements"] = measurements
524
+ fix["latest"] = measurements[-1] if measurements else None
525
+ return fix
526
+
527
+
528
+ def all_fixes_with_latest(conn):
529
+ rows = get_all_fixes(conn)
530
+ out = []
531
+ for r in rows:
532
+ r["baseline"] = json.loads(r.pop("baseline_json") or "{}")
533
+ latest = get_latest_fix_measurement(conn, r["id"])
534
+ if latest:
535
+ latest["metrics"] = json.loads(latest.pop("metrics_json") or "{}")
536
+ latest["delta"] = json.loads(latest.pop("delta_json") or "{}")
537
+ r["latest"] = latest
538
+ out.append(r)
539
+ return out