Skip to content

Commit 40f4828

Browse files
aqua5230claude
andcommitted
refactor(report): make insights cross-period and non-redundant
Replace the descriptive insight set (which restated the cards/project/ model sections and repeated the spike three times) with a deterministic, de-duplicated set of at most five lines: period-over-period change, single spike day, one shift (new project / model share / trend), pace, and one tied action. reporter.build_report_data now emits a `comparison` block (previous- period tokens/cost/projects/model-share) for week/month; insights.py consumes it. Engine stays local deterministic rules — no LLM, no network, no conversation-log reading. i18n updated across five locales; golden snapshots regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 85a828c commit 40f4828

9 files changed

Lines changed: 593 additions & 387 deletions

File tree

analyzer/insights.py

Lines changed: 171 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,201 @@
11
from __future__ import annotations
22

3+
from datetime import date
34
from math import sqrt
45
from typing import Any, cast
56

6-
INSIGHT_PRIORITY_SUMMARY = "priority_summary"
7-
INSIGHT_SUBSCRIPTION_VALUE = "subscription_value"
8-
INSIGHT_SPIKE_EXPLAINER = "spike_explainer"
9-
INSIGHT_NEXT_ACTIONS = "next_actions"
7+
INSIGHT_CHANGE_HEADLINE = "change_headline"
8+
INSIGHT_SPIKE = "spike"
9+
INSIGHT_SHIFT = "shift"
10+
INSIGHT_PACE_NOTE = "pace_note"
11+
INSIGHT_ACTION = "action"
1012

1113
_SPIKE_MULTIPLIER_THRESHOLD = 1.5
1214

1315

1416
def build_insights(data: dict[str, Any]) -> list[dict[str, Any]]:
1517
components: list[dict[str, Any]] = []
18+
19+
change = _build_change_headline(data)
20+
if change is not None:
21+
components.append(change)
22+
1623
spike = _find_spike(data.get("daily_trend"))
17-
subscription_value = _build_subscription_value(data)
24+
if spike is not None:
25+
components.append(
26+
{
27+
"type": INSIGHT_SPIKE,
28+
"key": "insights_spike_v2",
29+
"date": spike["date"],
30+
"tokens": spike["tokens"],
31+
"mean_multiplier": spike["mean_multiplier"],
32+
}
33+
)
1834

19-
priority_summary = _build_priority_summary(data, spike)
20-
if priority_summary is not None:
21-
components.append(priority_summary)
35+
shift = _build_shift(data)
36+
if shift is not None:
37+
components.append(shift)
2238

23-
if subscription_value is not None:
24-
components.append(subscription_value)
39+
pace_note = _build_pace_note(data)
40+
if pace_note is not None:
41+
components.append(pace_note)
2542

26-
if spike is not None:
27-
components.append({"type": INSIGHT_SPIKE_EXPLAINER, **spike})
43+
action = _build_action(change, spike)
44+
if action is not None:
45+
components.append(action)
2846

29-
next_actions = _build_next_actions(data, spike, subscription_value)
30-
if next_actions is not None:
31-
components.append(next_actions)
47+
return components[:5]
3248

33-
return components
3449

50+
def _build_change_headline(data: dict[str, Any]) -> dict[str, Any] | None:
51+
comparison = _mapping_value(data.get("comparison"))
52+
if comparison is None or not bool(comparison.get("has_prev")):
53+
return None
3554

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"))
55+
prev_tokens = _int_value(comparison.get("prev_tokens"))
56+
if prev_tokens <= 0:
57+
return None
4358

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+
summary = _mapping_value(data.get("summary"))
60+
if summary is None:
61+
return None
5962

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"],
63+
cur_tokens = _int_value(summary.get("total_tokens"))
64+
delta_pct = round((cur_tokens - prev_tokens) / prev_tokens * 100)
65+
if delta_pct >= 8:
66+
key = "insights_change_up"
67+
direction = "up"
68+
elif delta_pct <= -8:
69+
key = "insights_change_down"
70+
direction = "down"
71+
else:
72+
key = "insights_change_flat"
73+
direction = "flat"
74+
75+
return {
76+
"type": INSIGHT_CHANGE_HEADLINE,
77+
"key": key,
78+
"tokens": cur_tokens,
79+
"cost_usd": _round_cost(_float_value(summary.get("cost_usd"))),
80+
"pct": abs(delta_pct),
81+
"direction": direction,
82+
"delta_pct": delta_pct,
83+
}
84+
85+
86+
def _build_shift(data: dict[str, Any]) -> dict[str, Any] | None:
87+
return (
88+
_build_new_project_shift(data)
89+
or _build_model_shift(data)
90+
or _build_trend_shift(data)
91+
)
92+
93+
94+
def _build_new_project_shift(data: dict[str, Any]) -> dict[str, Any] | None:
95+
comparison = _mapping_value(data.get("comparison"))
96+
if comparison is None or not bool(comparison.get("has_prev")):
97+
return None
98+
99+
prev_projects = {
100+
_str_value(project, "")
101+
for project in _list_value(comparison.get("prev_projects"))
102+
}
103+
for project in _list_value(data.get("by_project")):
104+
item = _mapping_value(project)
105+
if item is None:
106+
continue
107+
name = _str_value(item.get("project"), "unknown")
108+
pct = _float_value(item.get("pct"))
109+
if pct >= 15.0 and name not in prev_projects:
110+
return {
111+
"type": INSIGHT_SHIFT,
112+
"key": "insights_shift_new_project",
113+
"project": name,
114+
"pct": _round_pct(pct),
68115
}
69-
)
116+
return None
70117

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-
)
84118

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-
)
119+
def _build_model_shift(data: dict[str, Any]) -> dict[str, Any] | None:
120+
comparison = _mapping_value(data.get("comparison"))
121+
if comparison is None or not bool(comparison.get("has_prev")):
122+
return None
99123

100-
if not items:
124+
top_model = _first_mapping(data.get("by_model"))
125+
prev_model_share = _mapping_value(comparison.get("prev_model_share"))
126+
if top_model is None or prev_model_share is None:
101127
return None
102-
return {"type": INSIGHT_PRIORITY_SUMMARY, "items": items[:3]}
103128

129+
model = _str_value(top_model.get("model"), "unknown")
130+
pct = _float_value(top_model.get("pct"))
131+
prev_pct = _float_value(prev_model_share.get(model))
132+
if pct - prev_pct < 10.0:
133+
return None
104134

105-
def _build_subscription_value(data: dict[str, Any]) -> dict[str, Any] | None:
106-
subscriptions = _list_value(data.get("subscriptions"))
107-
if not subscriptions:
135+
return {
136+
"type": INSIGHT_SHIFT,
137+
"key": "insights_shift_model_up",
138+
"model": model,
139+
"prev_pct": _round_pct(prev_pct),
140+
"pct": _round_pct(pct),
141+
}
142+
143+
144+
def _build_trend_shift(data: dict[str, Any]) -> dict[str, Any] | None:
145+
weekly = _weekly_token_totals(data.get("daily_trend"))
146+
if len(weekly) < 2:
108147
return None
148+
149+
if len(weekly) >= 3 and weekly[-3] < weekly[-2] < weekly[-1]:
150+
return {"type": INSIGHT_SHIFT, "key": "insights_shift_trend_up"}
151+
if weekly[-2] > 0 and weekly[-1] <= weekly[-2] * 0.75:
152+
return {"type": INSIGHT_SHIFT, "key": "insights_shift_trend_down"}
153+
return None
154+
155+
156+
def _build_pace_note(data: dict[str, Any]) -> dict[str, Any] | None:
109157
summary = _mapping_value(data.get("summary"))
110158
if summary is None:
111159
return None
112160

113161
active_days = _int_value(summary.get("active_days"))
114-
total_days = _int_value(summary.get("total_days"))
115162
sessions = _int_value(summary.get("sessions"))
116-
if total_days <= 0:
163+
if active_days <= 0:
117164
return None
118165

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"
166+
per_day = round(sessions / active_days)
167+
if per_day < 12:
168+
return None
126169

127170
return {
128-
"type": INSIGHT_SUBSCRIPTION_VALUE,
129-
"key": tier_key,
171+
"type": INSIGHT_PACE_NOTE,
172+
"key": "insights_pace_dense",
130173
"active_days": active_days,
131-
"total_days": total_days,
132-
"active_ratio": active_ratio,
133174
"sessions": sessions,
134-
"subscription_count": len(subscriptions),
175+
"per_day": per_day,
135176
}
136177

137178

179+
def _build_action(
180+
change: dict[str, Any] | None,
181+
spike: dict[str, Any] | None,
182+
) -> dict[str, Any] | None:
183+
if (
184+
change is not None
185+
and change.get("direction") == "up"
186+
and _int_value(change.get("delta_pct")) >= 50
187+
):
188+
return {"type": INSIGHT_ACTION, "key": "insights_action_watch_quota"}
189+
190+
if spike is not None:
191+
return {
192+
"type": INSIGHT_ACTION,
193+
"key": "insights_action_smooth_spike",
194+
"date": spike["date"],
195+
}
196+
return None
197+
198+
138199
def _find_spike(raw_daily: object) -> dict[str, Any] | None:
139200
daily = _daily_points(raw_daily)
140201
if len(daily) < 2:
@@ -169,63 +230,6 @@ def _find_spike(raw_daily: object) -> dict[str, Any] | None:
169230
}
170231

171232

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-
229233
def _daily_points(raw_daily: object) -> list[dict[str, Any]]:
230234
daily = _list_value(raw_daily)
231235
points: list[dict[str, Any]] = []
@@ -247,6 +251,23 @@ def _daily_points(raw_daily: object) -> list[dict[str, Any]]:
247251
return points
248252

249253

254+
def _weekly_token_totals(raw_daily: object) -> list[int]:
255+
daily = _daily_points(raw_daily)
256+
if not daily:
257+
return []
258+
259+
weekly: dict[tuple[int, int], int] = {}
260+
for point in daily:
261+
try:
262+
parsed = date.fromisoformat(point["date"][:10])
263+
except ValueError:
264+
continue
265+
iso_year, iso_week, _weekday = parsed.isocalendar()
266+
key = (iso_year, iso_week)
267+
weekly[key] = weekly.get(key, 0) + point["tokens"]
268+
return [weekly[key] for key in sorted(weekly)]
269+
270+
250271
def _first_mapping(value: object) -> dict[str, Any] | None:
251272
items = _list_value(value)
252273
if not items:

0 commit comments

Comments
 (0)