11from __future__ import annotations
22
3+ from datetime import date
34from math import sqrt
45from 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
1416def 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+
138199def _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-
229233def _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+
250271def _first_mapping (value : object ) -> dict [str , Any ] | None :
251272 items = _list_value (value )
252273 if not items :
0 commit comments