Skip to content

Commit 85a828c

Browse files
aqua5230claude
andcommitted
feat(report): add deterministic insights surface to HTML report
Render an "Insights" section between the summary cards and the tools section, driven by analyzer/insights.build_insights(). Engine is local deterministic rules (no LLM, no network, no conversation-log reading) emitting language-neutral schema; ui/html_report renders it via a fixed component catalog with full escaping. i18n keys added across all five locales. Golden snapshots regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 78dcd10 commit 85a828c

6 files changed

Lines changed: 651 additions & 3 deletions

File tree

analyzer/insights.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
from __future__ import annotations
2+
3+
from math import sqrt
4+
from typing import Any, cast
5+
6+
INSIGHT_PRIORITY_SUMMARY = "priority_summary"
7+
INSIGHT_SUBSCRIPTION_VALUE = "subscription_value"
8+
INSIGHT_SPIKE_EXPLAINER = "spike_explainer"
9+
INSIGHT_NEXT_ACTIONS = "next_actions"
10+
11+
_SPIKE_MULTIPLIER_THRESHOLD = 1.5
12+
13+
14+
def build_insights(data: dict[str, Any]) -> list[dict[str, Any]]:
15+
components: list[dict[str, Any]] = []
16+
spike = _find_spike(data.get("daily_trend"))
17+
subscription_value = _build_subscription_value(data)
18+
19+
priority_summary = _build_priority_summary(data, spike)
20+
if priority_summary is not None:
21+
components.append(priority_summary)
22+
23+
if subscription_value is not None:
24+
components.append(subscription_value)
25+
26+
if spike is not None:
27+
components.append({"type": INSIGHT_SPIKE_EXPLAINER, **spike})
28+
29+
next_actions = _build_next_actions(data, spike, subscription_value)
30+
if next_actions is not None:
31+
components.append(next_actions)
32+
33+
return components
34+
35+
36+
def _build_priority_summary(
37+
data: dict[str, Any],
38+
spike: dict[str, Any] | None,
39+
) -> dict[str, Any] | None:
40+
items: list[dict[str, Any]] = []
41+
top_project = _first_mapping(data.get("by_project"))
42+
top_model = _first_mapping(data.get("by_model"))
43+
44+
if top_project is not None:
45+
project_tokens = _int_value(top_project.get("tokens"))
46+
project_cost = _float_value(top_project.get("cost"))
47+
project_pct = _float_value(top_project.get("pct"))
48+
if project_tokens > 0 or project_cost > 0.0:
49+
items.append(
50+
{
51+
"key": "insights_priority_top_project",
52+
"project": _str_value(top_project.get("project"), "unknown"),
53+
"tokens": project_tokens,
54+
"cost_usd": _round_cost(project_cost),
55+
"pct": _round_pct(project_pct),
56+
"sessions": _int_value(top_project.get("sessions")),
57+
}
58+
)
59+
60+
if spike is not None:
61+
items.append(
62+
{
63+
"key": "insights_priority_spike_day",
64+
"date": spike["date"],
65+
"tokens": spike["tokens"],
66+
"mean_tokens": spike["mean_tokens"],
67+
"mean_multiplier": spike["mean_multiplier"],
68+
}
69+
)
70+
71+
if top_model is not None:
72+
model_tokens = _int_value(top_model.get("tokens"))
73+
model_pct = _float_value(top_model.get("pct"))
74+
if model_tokens > 0:
75+
items.append(
76+
{
77+
"key": "insights_priority_top_model",
78+
"model": _str_value(top_model.get("model"), "unknown"),
79+
"tokens": model_tokens,
80+
"pct": _round_pct(model_pct),
81+
"cost_usd": _round_cost(_float_value(top_model.get("cost"))),
82+
}
83+
)
84+
85+
summary = _mapping_value(data.get("summary"))
86+
if len(items) < 3 and summary is not None:
87+
total_cost = _float_value(summary.get("cost_usd"))
88+
total_tokens = _int_value(summary.get("total_tokens"))
89+
sessions = _int_value(summary.get("sessions"))
90+
if total_cost > 0.0 or total_tokens > 0:
91+
items.append(
92+
{
93+
"key": "insights_priority_total_usage",
94+
"cost_usd": _round_cost(total_cost),
95+
"tokens": total_tokens,
96+
"sessions": sessions,
97+
}
98+
)
99+
100+
if not items:
101+
return None
102+
return {"type": INSIGHT_PRIORITY_SUMMARY, "items": items[:3]}
103+
104+
105+
def _build_subscription_value(data: dict[str, Any]) -> dict[str, Any] | None:
106+
subscriptions = _list_value(data.get("subscriptions"))
107+
if not subscriptions:
108+
return None
109+
summary = _mapping_value(data.get("summary"))
110+
if summary is None:
111+
return None
112+
113+
active_days = _int_value(summary.get("active_days"))
114+
total_days = _int_value(summary.get("total_days"))
115+
sessions = _int_value(summary.get("sessions"))
116+
if total_days <= 0:
117+
return None
118+
119+
active_ratio = round(active_days / total_days, 3)
120+
if active_ratio >= 0.6 and sessions >= 12:
121+
tier_key = "insights_subscription_high"
122+
elif active_ratio >= 0.3 and sessions >= 5:
123+
tier_key = "insights_subscription_medium"
124+
else:
125+
tier_key = "insights_subscription_low"
126+
127+
return {
128+
"type": INSIGHT_SUBSCRIPTION_VALUE,
129+
"key": tier_key,
130+
"active_days": active_days,
131+
"total_days": total_days,
132+
"active_ratio": active_ratio,
133+
"sessions": sessions,
134+
"subscription_count": len(subscriptions),
135+
}
136+
137+
138+
def _find_spike(raw_daily: object) -> dict[str, Any] | None:
139+
daily = _daily_points(raw_daily)
140+
if len(daily) < 2:
141+
return None
142+
143+
token_values = [point["tokens"] for point in daily]
144+
mean = sum(token_values) / len(token_values)
145+
if mean <= 0.0:
146+
return None
147+
148+
variance = sum((tokens - mean) ** 2 for tokens in token_values) / len(token_values)
149+
stdev = sqrt(variance)
150+
threshold = mean + stdev
151+
152+
candidates = [
153+
point
154+
for point in daily
155+
if point["tokens"] > threshold
156+
and point["tokens"] >= mean * _SPIKE_MULTIPLIER_THRESHOLD
157+
]
158+
if not candidates:
159+
return None
160+
161+
spike = sorted(candidates, key=lambda point: (-point["tokens"], point["date"]))[0]
162+
return {
163+
"date": spike["date"],
164+
"tokens": spike["tokens"],
165+
"cost_usd": _round_cost(spike["cost"]),
166+
"mean_tokens": round(mean, 1),
167+
"stdev_tokens": round(stdev, 1),
168+
"mean_multiplier": round(spike["tokens"] / mean, 2),
169+
}
170+
171+
172+
def _build_next_actions(
173+
data: dict[str, Any],
174+
spike: dict[str, Any] | None,
175+
subscription_value: dict[str, Any] | None,
176+
) -> dict[str, Any] | None:
177+
actions: list[dict[str, Any]] = []
178+
179+
if spike is not None:
180+
actions.append(
181+
{
182+
"key": "insights_action_smooth_spikes",
183+
"date": spike["date"],
184+
"tokens": spike["tokens"],
185+
"mean_multiplier": spike["mean_multiplier"],
186+
}
187+
)
188+
189+
top_project = _first_mapping(data.get("by_project"))
190+
if top_project is not None:
191+
project_pct = _float_value(top_project.get("pct"))
192+
if project_pct >= 60.0:
193+
actions.append(
194+
{
195+
"key": "insights_action_split_heavy_project",
196+
"project": _str_value(top_project.get("project"), "unknown"),
197+
"pct": _round_pct(project_pct),
198+
"tokens": _int_value(top_project.get("tokens")),
199+
}
200+
)
201+
202+
top_model = _first_mapping(data.get("by_model"))
203+
if top_model is not None:
204+
model_pct = _float_value(top_model.get("pct"))
205+
if model_pct >= 70.0:
206+
actions.append(
207+
{
208+
"key": "insights_action_review_model_mix",
209+
"model": _str_value(top_model.get("model"), "unknown"),
210+
"pct": _round_pct(model_pct),
211+
"tokens": _int_value(top_model.get("tokens")),
212+
}
213+
)
214+
215+
if subscription_value is not None and subscription_value["key"] == "insights_subscription_low":
216+
actions.append(
217+
{
218+
"key": "insights_action_batch_sessions",
219+
"active_ratio": subscription_value["active_ratio"],
220+
"sessions": subscription_value["sessions"],
221+
}
222+
)
223+
224+
if not actions:
225+
return None
226+
return {"type": INSIGHT_NEXT_ACTIONS, "actions": actions[:3]}
227+
228+
229+
def _daily_points(raw_daily: object) -> list[dict[str, Any]]:
230+
daily = _list_value(raw_daily)
231+
points: list[dict[str, Any]] = []
232+
for raw_point in daily:
233+
point = _mapping_value(raw_point)
234+
if point is None:
235+
continue
236+
date = _str_value(point.get("date"), "")
237+
tokens = _int_value(point.get("tokens"))
238+
if not date or tokens < 0:
239+
continue
240+
points.append(
241+
{
242+
"date": date,
243+
"tokens": tokens,
244+
"cost": _float_value(point.get("cost")),
245+
}
246+
)
247+
return points
248+
249+
250+
def _first_mapping(value: object) -> dict[str, Any] | None:
251+
items = _list_value(value)
252+
if not items:
253+
return None
254+
return _mapping_value(items[0])
255+
256+
257+
def _mapping_value(value: object) -> dict[str, Any] | None:
258+
if isinstance(value, dict):
259+
return cast(dict[str, Any], value)
260+
return None
261+
262+
263+
def _list_value(value: object) -> list[object]:
264+
if isinstance(value, list):
265+
return value
266+
return []
267+
268+
269+
def _str_value(value: object, default: str) -> str:
270+
if isinstance(value, str):
271+
return value
272+
return default
273+
274+
275+
def _int_value(value: object) -> int:
276+
if isinstance(value, bool):
277+
return 0
278+
if isinstance(value, int):
279+
return value
280+
if isinstance(value, float):
281+
return int(value)
282+
return 0
283+
284+
285+
def _float_value(value: object) -> float:
286+
if isinstance(value, bool):
287+
return 0.0
288+
if isinstance(value, int | float):
289+
return float(value)
290+
return 0.0
291+
292+
293+
def _round_cost(value: float) -> float:
294+
return round(value, 4)
295+
296+
297+
def _round_pct(value: float) -> float:
298+
return round(value, 1)

0 commit comments

Comments
 (0)