77cur = c .cursor ()
88Q = lambda s , * a : cur .execute (s , a ).fetchall ()
99
10+ # ── finestra opzionale di sessione (per NON mescolare run diversi) ──────
11+ # Env:
12+ # JHT_CS_SOURCE etichetta della sorgente nello snapshot (default: andris-codex)
13+ # JHT_CS_FROM inizio finestra inclusivo "YYYY-MM-DD" (vuoto = dall'inizio)
14+ # JHT_CS_TO fine finestra ESCLUSIVA "YYYY-MM-DD" (vuoto = fino a oggi)
15+ # JHT_CS_PROVIDER forza il provider del budget (es. codex/kimi); vuoto = dai dati
16+ # La finestra filtra positions.found_at, position_state_transitions.ts e i sample
17+ # di budget. È realizzata con TEMP TABLE che SHADOWANO le tabelle reali: in SQLite
18+ # il nome non qualificato risolve prima sulla TEMP, quindi TUTTE le query sotto
19+ # restano identiche. Il DB principale resta read-only (le TEMP vivono nello store
20+ # temporaneo, separato). Senza env → copie integrali = comportamento invariato.
21+ SOURCE = os .environ .get ("JHT_CS_SOURCE" , "" ).strip () or "andris-codex"
22+ FROM = os .environ .get ("JHT_CS_FROM" , "" ).strip ()
23+ TO = os .environ .get ("JHT_CS_TO" , "" ).strip ()
24+
25+ def _win (col ):
26+ conds , params = [], []
27+ if FROM :
28+ conds .append (f"{ col } >= ?" ); params .append (FROM )
29+ if TO :
30+ conds .append (f"{ col } < ?" ); params .append (TO )
31+ return (" AND " + " AND " .join (conds )) if conds else "" , params
32+
33+ _pf , _pp = _win ("found_at" )
34+ cur .execute (f"CREATE TEMP TABLE positions AS SELECT * FROM main.positions WHERE 1=1{ _pf } " , _pp )
35+ cur .execute ("CREATE TEMP TABLE scores AS SELECT * FROM main.scores "
36+ "WHERE position_id IN (SELECT id FROM positions)" )
37+ _ef , _ep = _win ("ts" )
38+ cur .execute ("CREATE TEMP TABLE position_state_transitions AS "
39+ f"SELECT * FROM main.position_state_transitions WHERE 1=1{ _ef } " , _ep )
40+
1041# ── totals ────────────────────────────────────────────────────────────
1142positions = Q ("SELECT COUNT(*) FROM positions" )[0 ][0 ]
1243scored_status = Q ("SELECT COUNT(*) FROM positions WHERE status='scored'" )[0 ][0 ]
@@ -207,6 +238,10 @@ def norm(s):
207238 ts = e .get ("ts" , "" )
208239 if not ts or e .get ("weekly_usage" ) is None :
209240 continue
241+ if FROM and ts [:10 ] < FROM : # stessa finestra di sessione delle positions
242+ continue
243+ if TO and ts [:10 ] >= TO :
244+ continue
210245 samples .append ((ts , e ["weekly_usage" ]))
211246 if e .get ("provider" ):
212247 provider = e ["provider" ]
@@ -251,6 +286,7 @@ def norm(s):
251286 }
252287 except Exception :
253288 pass
289+ provider = os .environ .get ("JHT_CS_PROVIDER" , "" ).strip () or provider
254290 usage = {"provider" : provider , "unit" : "weekly_budget_pct" ,
255291 "daily" : daily , "workingHours" : working_hours }
256292
@@ -301,8 +337,45 @@ def norm(s):
301337 "scored" : status_tot .get ("scored" , 0 ), "ready" : status_tot .get ("ready" , 0 ),
302338}
303339
340+ # ── imbuto di conversione (POSIZIONI DISTINTE, monotòno) ──────────────
341+ # La card di conversione (trovate → valutate → forti≥70 → eccellenti≥80) deve
342+ # decrescere step-su-step. Perciò NON usa funnelTotals.scored (posizioni nello
343+ # stato 'scored' → sottostima quando avanzano a 'ready') né match.strong70/80
344+ # (che contano gli EVENTI di score, ri-score inclusi → sovrastima). Qui ogni
345+ # stadio è una POSIZIONE DISTINTA, con la soglia sul MIGLIOR punteggio ricevuto.
346+ conv_scored = Q ("SELECT COUNT(DISTINCT position_id) FROM scores "
347+ "WHERE total_score IS NOT NULL" )[0 ][0 ]
348+ _best = Q ("SELECT MAX(total_score) FROM scores WHERE total_score IS NOT NULL "
349+ "GROUP BY position_id" )
350+ conversion = {
351+ "found" : _found_tot ,
352+ "scored" : conv_scored ,
353+ "strong70" : sum (1 for (m ,) in _best if m >= 70 ),
354+ "strong80" : sum (1 for (m ,) in _best if m >= 80 ),
355+ }
356+
357+ # ── per-giorno (found_at): match forti/eccellenti prodotti ────────────
358+ # Per ogni giorno di found_at: quante posizioni hanno il MIGLIOR punteggio
359+ # ≥70 (forti) e ≥80 (eccellenti). Serve al grafico temporale "score alto al
360+ # giorno". Bucket per giorno di scoperta, coerente con funnelDaily/sourcesDaily.
361+ _bd = Q ("SELECT substr(p.found_at,1,10) d, MAX(s.total_score) best "
362+ "FROM positions p JOIN scores s ON s.position_id=p.id "
363+ "WHERE p.found_at IS NOT NULL AND TRIM(p.found_at)<>'' "
364+ "AND s.total_score IS NOT NULL GROUP BY p.id" )
365+ _sd = collections .OrderedDict ()
366+ for d , best in _bd :
367+ b = _sd .setdefault (d , {"scored" : 0 , "strong70" : 0 , "strong80" : 0 })
368+ b ["scored" ] += 1
369+ if best >= 70 :
370+ b ["strong70" ] += 1
371+ if best >= 80 :
372+ b ["strong80" ] += 1
373+ score_daily = [{"day" : d , "scored" : _sd [d ]["scored" ],
374+ "strong70" : _sd [d ]["strong70" ], "strong80" : _sd [d ]["strong80" ]}
375+ for d in sorted (_sd )]
376+
304377out = {
305- "source" : "andris-codex" ,
378+ "source" : SOURCE ,
306379 "tsRange" : [ts_min , ts_max ],
307380 "totals" : {"positions" : positions , "scored" : scored_status , "excluded" : excluded_status },
308381 "match" : match ,
@@ -320,6 +393,8 @@ def norm(s):
320393 "hourly" : hourly ,
321394 "funnelDaily" : funnel_daily ,
322395 "funnelTotals" : funnel_totals ,
396+ "conversion" : conversion ,
397+ "scoreDaily" : score_daily ,
323398 "usage" : usage ,
324399}
325400print (json .dumps (out , ensure_ascii = False , indent = 2 ))
0 commit comments