Skip to content

Commit 7ef5b8a

Browse files
committed
merge: dev7 → master — case-studies: statistiche per provider (tabella-imbuto unica, toggle Kimi/Codex) + funnel invertito, match/giorno, prezzo per risultato, leggibilità
2 parents f6cd0bd + 2e67995 commit 7ef5b8a

18 files changed

Lines changed: 3843 additions & 6097 deletions

tools/case-study-extract/build_case_study_json.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,37 @@
77
cur = c.cursor()
88
Q = 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 ────────────────────────────────────────────────────────────
1142
positions = Q("SELECT COUNT(*) FROM positions")[0][0]
1243
scored_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+
304377
out = {
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
}
325400
print(json.dumps(out, ensure_ascii=False, indent=2))

web/app/case-studies/CaseStudiesShell.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from "react";
44
import Link from "next/link";
5+
import { rememberCurrentSection } from "@/lib/case-study-scroll";
56
import { useLocale } from "@/lib/use-locale";
67
import type { Locale } from "@/i18n/config";
78

@@ -96,14 +97,19 @@ export interface CaseStudyTeaser {
9697
seniority: string;
9798
geos: string[];
9899
model: string;
100+
/** periodo di lavoro già formattato: es. "19 mag → 30 giu (34 giorni)" */
101+
period: string;
99102
}
100103

101104
export default function CaseStudiesShell({
102105
testers,
103106
children,
107+
activeId,
104108
}: {
105109
testers: CaseStudyTeaser[];
106110
children: React.ReactNode;
111+
/** id del tester corrente (nelle pagine di dettaglio): la sua voce è evidenziata */
112+
activeId?: string;
107113
}) {
108114
const [collapsed, setCollapsed] = useState(false);
109115
const t = T[useLocale()];
@@ -142,11 +148,32 @@ export default function CaseStudiesShell({
142148
</div>
143149

144150
<nav className="grid grid-cols-1 auto-rows-fr gap-2.5">
145-
{testers.map((t) => (
151+
{testers.map((t) => {
152+
const active = t.id === activeId;
153+
return (
146154
<Link
147155
key={t.id}
148156
href={`/case-studies/${t.id}`}
149-
className="group flex h-full flex-col rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] px-3.5 py-3 no-underline transition-colors hover:border-[var(--color-blue)]"
157+
// Su una pagina di dettaglio (activeId presente) non riportare in
158+
// cima: ricordiamo la sezione corrente e la riallineiamo sul nuovo
159+
// tester (restoreSection nel dettaglio). Dall'indice invece si entra
160+
// in cima alla pagina scelta.
161+
scroll={!activeId}
162+
onClick={activeId ? rememberCurrentSection : undefined}
163+
aria-current={active ? "page" : undefined}
164+
className={`group flex h-full flex-col rounded-xl border ${
165+
active
166+
? "border-[var(--color-blue)]"
167+
: "border-[var(--color-border)]"
168+
} bg-[var(--color-card)] px-3.5 py-3 no-underline transition-colors hover:border-[var(--color-blue)]`}
169+
style={
170+
active
171+
? {
172+
background:
173+
"color-mix(in srgb, var(--color-blue) 10%, var(--color-card))",
174+
}
175+
: undefined
176+
}
150177
>
151178
<div className="min-w-0 flex-1">
152179
<div className="flex items-center gap-2">
@@ -189,9 +216,15 @@ export default function CaseStudiesShell({
189216
{t.model}
190217
</span>
191218
</div>
219+
220+
{/* periodo di lavoro (finestra + giorni), breve */}
221+
<div className="mt-2 text-[9px] tabular-nums text-[var(--color-dim)]">
222+
{t.period}
223+
</div>
192224
</div>
193225
</Link>
194-
))}
226+
);
227+
})}
195228
</nav>
196229
</div>
197230
</aside>

0 commit comments

Comments
 (0)