zeno-cli 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. zeno_adapters/__init__.py +17 -0
  2. zeno_adapters/_common.py +38 -0
  3. zeno_adapters/anthropic.py +68 -0
  4. zeno_adapters/claude_code.py +101 -0
  5. zeno_adapters/crewai.py +92 -0
  6. zeno_adapters/langgraph.py +49 -0
  7. zeno_adapters/openai.py +108 -0
  8. zeno_cli/__init__.py +1 -0
  9. zeno_cli/_hooks/cc_bridge.py +1016 -0
  10. zeno_cli/doctor.py +535 -0
  11. zeno_cli/hook_install.py +269 -0
  12. zeno_cli/hud/__init__.py +1 -0
  13. zeno_cli/hud/hud_install.py +652 -0
  14. zeno_cli/hud/zeno_attention.py +288 -0
  15. zeno_cli/hud/zeno_cognition.py +457 -0
  16. zeno_cli/hud/zeno_hud.py +496 -0
  17. zeno_cli/interview_invites.py +342 -0
  18. zeno_cli/login.py +241 -0
  19. zeno_cli/main.py +2534 -0
  20. zeno_cli/onboard.py +206 -0
  21. zeno_cli/outreach.py +456 -0
  22. zeno_cli/version.py +67 -0
  23. zeno_cli-0.3.4.dist-info/METADATA +161 -0
  24. zeno_cli-0.3.4.dist-info/RECORD +69 -0
  25. zeno_cli-0.3.4.dist-info/WHEEL +4 -0
  26. zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
  27. zeno_core/__init__.py +67 -0
  28. zeno_core/analytics.py +193 -0
  29. zeno_core/rtlx_s.py +460 -0
  30. zeno_core/streak.py +178 -0
  31. zeno_core/tlx_s.py +192 -0
  32. zeno_sdk/__init__.py +6 -0
  33. zeno_sdk/_generated/__init__.py +6 -0
  34. zeno_sdk/_generated/client.py +819 -0
  35. zeno_sdk/_migrations/alembic/env.py +33 -0
  36. zeno_sdk/_migrations/alembic/script.py.mako +18 -0
  37. zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
  38. zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
  39. zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
  40. zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
  41. zeno_sdk/_migrations/alembic.ini +35 -0
  42. zeno_sdk/_runtime.py +12 -0
  43. zeno_sdk/adapters/__init__.py +15 -0
  44. zeno_sdk/adapters/anthropic.py +5 -0
  45. zeno_sdk/adapters/claude_code.py +5 -0
  46. zeno_sdk/adapters/crewai.py +5 -0
  47. zeno_sdk/adapters/langgraph.py +5 -0
  48. zeno_sdk/adapters/openai.py +5 -0
  49. zeno_sdk/auth.py +25 -0
  50. zeno_sdk/client.py +87 -0
  51. zeno_sdk/config.py +61 -0
  52. zeno_sdk/daemon.py +72 -0
  53. zeno_sdk/privacy.py +46 -0
  54. zeno_sdk/session.py +179 -0
  55. zeno_sdk/storage.py +487 -0
  56. zeno_sdk/types/__init__.py +121 -0
  57. zeno_session_intel/__init__.py +19 -0
  58. zeno_session_intel/analytics.py +588 -0
  59. zeno_session_intel/compression.py +123 -0
  60. zeno_session_intel/ingest.py +376 -0
  61. zeno_session_intel/model.py +129 -0
  62. zeno_session_intel/parsers/__init__.py +31 -0
  63. zeno_session_intel/parsers/claude_code.py +169 -0
  64. zeno_session_intel/parsers/codex.py +265 -0
  65. zeno_session_intel/parsers/cursor.py +198 -0
  66. zeno_session_intel/prices.py +281 -0
  67. zeno_session_intel/schema.py +277 -0
  68. zeno_session_intel/signals.py +319 -0
  69. zeno_session_intel/taxonomy.py +71 -0
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env python3
2
+ """zeno_cognition - the v2 multi-dimensional cognition model (single source of truth).
3
+
4
+ v1 (zeno_attention.py) produced ONE attention score by re-parsing the transcript
5
+ tail at render time; in practice it sat flat near 50. v2 captures signal at EVENT
6
+ TIME (in the cc-bridge hook) across five named drivers and composes them into one
7
+ attention score, so both the terminal HUD and the dashboard read the same per-turn
8
+ row and agree by construction.
9
+
10
+ The five drivers (each a raw 0..1, then standardized against the user's OWN rolling
11
+ baseline because absolute values vary 3-4x between people):
12
+
13
+ effort how hard the human is driving (real prompt richness + reasoning level)
14
+ autonomy AI-led vs human-driven (tool activity vs human input) - drift risk
15
+ verification supervision/review cost (review gap, accept/reject, "fix it", churn)
16
+ fatigue accumulated time-on-task decay (vigilance decrement; a PENALTY)
17
+ flow rhythm: steady cadence vs rapid-fire thrash vs irregular fatigue pauses
18
+
19
+ Honesty: this is a transparent, tunable v1 HEURISTIC, not a validated instrument.
20
+ Every weight is an env var; scores are relative to the user's own baseline and read
21
+ "calibrating" until enough history exists; it is NOT calibrated against the RTLX-S
22
+ survey (the pre-registered SCED stays untouched). The research behind each driver is
23
+ real but qualified (behavioral proxies reach AUC ~72-80% for fatigue, not certainty)
24
+ - so we show the drivers alongside the composite and never claim measurement.
25
+
26
+ Design: stdlib-only, Python 3.9+ safe, never raises. Imported by the cc-bridge hook
27
+ (the writer), the HUD (the reader), and the dashboard exporter.
28
+ """
29
+
30
+ import json
31
+ import math
32
+ import os
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # tunable weights (env-overridable; the composite is a transparent weighted sum)
36
+ # effort/verification/flow raise attention; autonomy raises it (active hand-off
37
+ # still counts as engaged supervision up to a point); fatigue is a penalty.
38
+ # ---------------------------------------------------------------------------
39
+ DRIVERS = ("effort", "autonomy", "verification", "fatigue", "flow")
40
+
41
+
42
+ def _w(name, default):
43
+ try:
44
+ return float(os.environ.get(name, str(default)))
45
+ except Exception:
46
+ return default
47
+
48
+
49
+ W_EFFORT = _w("ZENO_COG_W_EFFORT", 0.30)
50
+ W_VERIF = _w("ZENO_COG_W_VERIF", 0.25)
51
+ W_AUTONOMY = _w("ZENO_COG_W_AUTONOMY", 0.20)
52
+ W_FLOW = _w("ZENO_COG_W_FLOW", 0.15)
53
+ W_FATIGUE = _w("ZENO_COG_W_FATIGUE", 0.10) # applied as a penalty
54
+
55
+ # How many baseline samples before a driver is "calibrated" (else "calibrating").
56
+ CALIBRATION_MIN = int(os.environ.get("ZENO_COG_CALIBRATION_MIN", "20"))
57
+ BASELINE_WINDOW = int(os.environ.get("ZENO_COG_BASELINE_WINDOW", "400"))
58
+ ATTENTION_RED = int(os.environ.get("ZENO_ATTENTION_RED", "55"))
59
+
60
+ # ANSI color codes (shared with the HUD); dashboard maps the same bands to its theme.
61
+ GREEN, YELLOW, RED, CYAN = "92", "93", "91", "96"
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # canonical cognition_samples schema (single source of truth for the stdlib
66
+ # writers - the HUD and the cc-bridge hook). Mirror any change here into the
67
+ # alembic migration (packages/sdk-python/alembic/versions/0003_cognition_drivers.py);
68
+ # the schema-drift test guards the two from diverging.
69
+ # ---------------------------------------------------------------------------
70
+ SAMPLE_COLUMNS = (
71
+ ("id", "TEXT PRIMARY KEY"),
72
+ ("session_id", "TEXT NOT NULL"),
73
+ ("ts", "TEXT NOT NULL"),
74
+ ("context_pct", "REAL"),
75
+ ("input_tokens", "INTEGER"),
76
+ ("output_tokens", "INTEGER"),
77
+ ("cache_read_tokens", "INTEGER"),
78
+ ("cache_creation_tokens", "INTEGER"),
79
+ ("total_tokens", "INTEGER"),
80
+ ("attention_score", "INTEGER"),
81
+ ("attention_effort", "REAL"),
82
+ ("attention_deliberation", "REAL"), # v1 legacy component, kept for back-compat
83
+ ("attention_trend", "REAL"),
84
+ ("model", "TEXT"),
85
+ ("harness", "TEXT"),
86
+ # --- v2 drivers ---
87
+ ("attention_autonomy", "REAL"),
88
+ ("attention_verification", "REAL"),
89
+ ("attention_fatigue", "REAL"),
90
+ ("attention_flow", "REAL"),
91
+ )
92
+
93
+ # the v2 driver columns, in DB form (attention_<driver>)
94
+ DRIVER_COLUMNS = tuple("attention_" + d for d in DRIVERS)
95
+
96
+
97
+ def ensure_schema(con):
98
+ """Create cognition_samples if missing and add any missing v2 driver columns.
99
+
100
+ Idempotent and migration-safe: works on a fresh DB and on a v1 DB (adds the
101
+ four driver columns via ALTER). Used by both stdlib writers so a fresh machine
102
+ works before the SDK alembic migration has ever run. Never raises."""
103
+ try:
104
+ cols_sql = ", ".join(f"{n} {t}" for n, t in SAMPLE_COLUMNS)
105
+ con.execute(f"CREATE TABLE IF NOT EXISTS cognition_samples ({cols_sql})")
106
+ con.execute(
107
+ "CREATE INDEX IF NOT EXISTS ix_cognition_samples_session_id "
108
+ "ON cognition_samples (session_id)"
109
+ )
110
+ existing = {r[1] for r in con.execute("PRAGMA table_info(cognition_samples)").fetchall()}
111
+ for name, decl in SAMPLE_COLUMNS:
112
+ if name not in existing:
113
+ base_type = decl.split()[0] # ALTER ADD cannot carry PK/NOT NULL
114
+ try:
115
+ con.execute(f"ALTER TABLE cognition_samples ADD COLUMN {name} {base_type}")
116
+ except Exception:
117
+ pass
118
+ except Exception:
119
+ pass
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # raw per-turn driver scoring (each returns 0..1)
124
+ # ---------------------------------------------------------------------------
125
+ def effort_raw(prompt_chars, has_code=False, multiline=False, effort_level=None, lead_word=False):
126
+ """How much effort the human put into driving this turn.
127
+
128
+ Long, structured, code-bearing prompts score high; one-word 'let the AI lead'
129
+ prompts score low. The model's reasoning effort.level nudges it up."""
130
+ n = max(0, int(prompt_chars or 0))
131
+ base = 1.0 - math.exp(-n / 180.0) # ~0 tiny, ~0.8 @300 chars, ->1 long
132
+ bonus = 0.0
133
+ if has_code or multiline:
134
+ bonus += 0.15
135
+ if lead_word and n <= 6:
136
+ base *= 0.25
137
+ lvl = {"low": 0.0, "medium": 0.04, "high": 0.08, "xhigh": 0.12, "max": 0.15}.get(
138
+ (effort_level or "").lower(), 0.0
139
+ )
140
+ return max(0.0, min(1.0, base + bonus + lvl))
141
+
142
+
143
+ def autonomy_raw(tool_uses, human_prompt_chars, autonomous_seconds):
144
+ """AI-led ratio: lots of tool activity + a thin human prompt + a long autonomous
145
+ stretch means the agent is driving. High autonomy is a drift-risk input (the
146
+ out-of-the-loop problem), not inherently bad - classify() decides if it matters."""
147
+ tools = max(0, int(tool_uses or 0))
148
+ human = max(0.0, float(human_prompt_chars or 0))
149
+ secs = max(0.0, float(autonomous_seconds or 0))
150
+ tool_load = 1.0 - math.exp(-tools / 6.0) # ~0.8 at 10 tool uses
151
+ thin_human = math.exp(-human / 120.0) # thin prompt -> closer to 1
152
+ long_run = 1.0 - math.exp(-secs / 240.0) # 4-min run -> ~0.6
153
+ return max(0.0, min(1.0, 0.5 * tool_load + 0.3 * thin_human + 0.2 * long_run))
154
+
155
+
156
+ def verification_raw(review_gap_s, reprompts=0, churn=0.0, accepts=0, rejects=0):
157
+ """How much the human is actually reviewing the agent's work: time spent before
158
+ the next action, correction re-prompts ('fix it'), edit churn, and accept/reject
159
+ activity. This is the supervision-cost signal."""
160
+ gap = max(0.0, float(review_gap_s or 0))
161
+ # a 30s-120s review window is healthy engagement; <4s is rubber-stamping
162
+ if gap < 4:
163
+ gap_score = 0.15
164
+ elif gap <= 180:
165
+ gap_score = min(1.0, gap / 90.0)
166
+ else:
167
+ gap_score = 0.6 # very long: still engaged, possibly distracted
168
+ decisions = max(0, int(accepts or 0)) + max(0, int(rejects or 0))
169
+ decide_score = 1.0 - math.exp(-decisions / 3.0)
170
+ reprompt_score = 1.0 - math.exp(-max(0, int(reprompts or 0)) / 2.0)
171
+ churn_score = max(0.0, min(1.0, float(churn or 0.0)))
172
+ return max(
173
+ 0.0,
174
+ min(1.0, 0.5 * gap_score + 0.2 * decide_score + 0.15 * reprompt_score + 0.15 * churn_score),
175
+ )
176
+
177
+
178
+ def fatigue_raw(active_minutes, inter_turn_var=0.0, hour=None):
179
+ """Accumulated time-on-task fatigue. The vigilance literature gives an exponential
180
+ decrement with a 20-30 min inflection and a clear arousal drop past ~60 min, so
181
+ fatigue rises with continuous active minutes; high inter-turn timing variability
182
+ and late hours add to it. Higher = more tired (a penalty on attention)."""
183
+ mins = max(0.0, float(active_minutes or 0))
184
+ # exponential approach to 1; ~0.4 at 30 min, ~0.7 at 60 min, ~0.9 at 120 min
185
+ time_fat = 1.0 - math.exp(-mins / 65.0)
186
+ var = max(0.0, min(1.0, float(inter_turn_var or 0.0)))
187
+ late = 0.0
188
+ if isinstance(hour, (int, float)):
189
+ h = int(hour) % 24
190
+ if h >= 22 or h < 6:
191
+ late = 0.15
192
+ return max(0.0, min(1.0, 0.75 * time_fat + 0.2 * var + late))
193
+
194
+
195
+ def flow_raw(cadence_regularity, boundary_pause_ratio=0.5):
196
+ """Rhythm quality. Flow shows steady cadence with pauses clustered at semantic
197
+ boundaries; thrash is rapid-fire; fatigue is irregular. Higher = better rhythm.
198
+ cadence_regularity and boundary_pause_ratio are each 0..1."""
199
+ reg = max(0.0, min(1.0, float(cadence_regularity or 0.0)))
200
+ boundary = max(
201
+ 0.0, min(1.0, float(boundary_pause_ratio if boundary_pause_ratio is not None else 0.5))
202
+ )
203
+ return max(0.0, min(1.0, 0.6 * reg + 0.4 * boundary))
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # per-user baselines (relative scoring; absolute values vary 3-4x between people)
208
+ # ---------------------------------------------------------------------------
209
+ class Baselines:
210
+ """Rolling per-driver baselines for robust standardization. Keeps the last
211
+ BASELINE_WINDOW raw values per driver and standardizes new values to a robust
212
+ z-score (median + IQR), so a driver reads 'relative to your own normal'. Stdlib,
213
+ JSON-backed, never raises into the caller."""
214
+
215
+ def __init__(self, data=None):
216
+ self.data = data or {}
217
+
218
+ @classmethod
219
+ def load(cls, path):
220
+ try:
221
+ if path and os.path.exists(path):
222
+ with open(path) as f:
223
+ return cls(json.load(f) or {})
224
+ except Exception:
225
+ pass
226
+ return cls({})
227
+
228
+ def save(self, path):
229
+ try:
230
+ if not path:
231
+ return
232
+ os.makedirs(os.path.dirname(path), exist_ok=True)
233
+ tmp = path + ".tmp"
234
+ with open(tmp, "w") as f:
235
+ json.dump(self.data, f)
236
+ os.replace(tmp, path)
237
+ except Exception:
238
+ pass
239
+
240
+ def _vals(self, driver):
241
+ v = self.data.get(driver)
242
+ return v if isinstance(v, list) else []
243
+
244
+ def update(self, driver, value):
245
+ try:
246
+ v = self._vals(driver)
247
+ v.append(round(float(value), 4))
248
+ if len(v) > BASELINE_WINDOW:
249
+ v = v[-BASELINE_WINDOW:]
250
+ self.data[driver] = v
251
+ except Exception:
252
+ pass
253
+
254
+ def n(self, driver):
255
+ return len(self._vals(driver))
256
+
257
+ def calibrating(self, driver):
258
+ return self.n(driver) < CALIBRATION_MIN
259
+
260
+ def standardize(self, driver, value):
261
+ """Return a 0..100 score: relative to the user's baseline once calibrated,
262
+ else the raw value scaled (cold start). 50 == your median."""
263
+ try:
264
+ x = float(value)
265
+ except Exception:
266
+ return None
267
+ vals = self._vals(driver)
268
+ if len(vals) < CALIBRATION_MIN:
269
+ return round(max(0.0, min(1.0, x)) * 100.0, 1) # cold start: raw 0..1 -> 0..100
270
+ s = sorted(vals)
271
+ med = _median(s)
272
+ iqr = _percentile(s, 75) - _percentile(s, 25)
273
+ scale = (iqr / 1.349) if iqr > 1e-6 else 0.15
274
+ z = (x - med) / scale
275
+ return round(_sigmoid(z) * 100.0, 1)
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # stats helpers
280
+ # ---------------------------------------------------------------------------
281
+ def _sigmoid(x):
282
+ try:
283
+ return 1.0 / (1.0 + math.exp(-max(-12.0, min(12.0, x))))
284
+ except Exception:
285
+ return 0.5
286
+
287
+
288
+ def _median(sorted_vals):
289
+ n = len(sorted_vals)
290
+ if n == 0:
291
+ return 0.0
292
+ m = n // 2
293
+ return sorted_vals[m] if n % 2 else (sorted_vals[m - 1] + sorted_vals[m]) / 2.0
294
+
295
+
296
+ def _percentile(sorted_vals, p):
297
+ n = len(sorted_vals)
298
+ if n == 0:
299
+ return 0.0
300
+ if n == 1:
301
+ return sorted_vals[0]
302
+ k = (p / 100.0) * (n - 1)
303
+ lo = int(math.floor(k))
304
+ hi = int(math.ceil(k))
305
+ if lo == hi:
306
+ return sorted_vals[lo]
307
+ return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (k - lo)
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # compose + classify
312
+ # ---------------------------------------------------------------------------
313
+ def compose(raw, baselines=None, recent_scores=None, context=None):
314
+ """Compose the five raw drivers (0..1 each) into the attention dict.
315
+
316
+ `raw`: {effort, autonomy, verification, fatigue, flow} in 0..1 (missing -> skipped).
317
+ `baselines`: a Baselines instance for relative standardization (optional).
318
+ `recent_scores`: recent composite scores for the trend (optional).
319
+ `context`: {long_session, churn, errors} for the context-aware nudge.
320
+
321
+ Returns {ok, score, drivers (0..100), top_driver, trend, label, nudge, glyph,
322
+ color, calibrating}. Never raises."""
323
+ try:
324
+ bl = baselines or Baselines({})
325
+ std = {}
326
+ for d in DRIVERS:
327
+ if d in raw and raw[d] is not None:
328
+ s = bl.standardize(d, raw[d])
329
+ if s is not None:
330
+ std[d] = s
331
+ if not std:
332
+ return {"ok": False}
333
+
334
+ def g(d, default=50.0):
335
+ return std.get(d, default)
336
+
337
+ # composite: effort/verification/flow/autonomy raise, fatigue penalizes.
338
+ wsum = W_EFFORT + W_VERIF + W_AUTONOMY + W_FLOW
339
+ pos = (
340
+ W_EFFORT * g("effort")
341
+ + W_VERIF * g("verification")
342
+ + W_AUTONOMY * g("autonomy")
343
+ + W_FLOW * g("flow")
344
+ ) / (wsum if wsum else 1.0)
345
+ penalty = W_FATIGUE * (g("fatigue", 0.0) / 100.0) * 100.0
346
+ score = int(round(max(0.0, min(100.0, pos - penalty))))
347
+
348
+ # top driver = largest absolute deviation from the user's median (50)
349
+ top = max(std.items(), key=lambda kv: abs(kv[1] - 50.0))[0]
350
+
351
+ # trend from recent composites
352
+ trend = 0.0
353
+ if recent_scores:
354
+ rs = [s for s in recent_scores if isinstance(s, (int, float))][-6:]
355
+ if len(rs) >= 4:
356
+ half = len(rs) // 2
357
+ trend = max(
358
+ -1.0,
359
+ min(
360
+ 1.0, ((sum(rs[half:]) / (len(rs) - half)) - (sum(rs[:half]) / half)) / 100.0
361
+ ),
362
+ )
363
+
364
+ calibrating = any(bl.calibrating(d) for d in std)
365
+ label, nudge, glyph, color = classify(score, trend, std, context or {})
366
+ return {
367
+ "ok": True,
368
+ "score": score,
369
+ "drivers": std,
370
+ "top_driver": top,
371
+ "trend": round(trend, 3),
372
+ "label": label,
373
+ "nudge": nudge,
374
+ "glyph": glyph,
375
+ "color": color,
376
+ "calibrating": calibrating,
377
+ }
378
+ except Exception:
379
+ return {"ok": False}
380
+
381
+
382
+ def classify(score, trend, drivers, context):
383
+ """Map score + drivers + context to (label, nudge, glyph, color).
384
+
385
+ Context-aware so we do not pathologize adaptive dips (Strategic Allocation
386
+ Theory): low engagement during a clean autonomous run is fine ('monitoring');
387
+ low engagement with churn/errors is drift ('step in'); long + tired is 'rest'."""
388
+ glyph = "▲" if trend > 0.08 else ("▼" if trend < -0.08 else "─")
389
+ long_session = bool(context.get("long_session"))
390
+ churn = float(context.get("churn", 0.0) or 0.0)
391
+ errors = bool(context.get("errors"))
392
+ autonomy = float(drivers.get("autonomy", 50.0))
393
+ verification = float(drivers.get("verification", 50.0))
394
+ fatigue = float(drivers.get("fatigue", 0.0))
395
+
396
+ if score >= 70:
397
+ label, nudge, col = "sharp", "keep going", GREEN
398
+ elif score >= 45:
399
+ label, nudge, col = "steady", "in rhythm", CYAN
400
+ elif score >= 25:
401
+ label, nudge, col = "easing", "wrap soon" if long_session else "ride it out", YELLOW
402
+ else:
403
+ label, nudge, col = "fading", "rest", RED
404
+
405
+ # context-aware overrides for the low bands
406
+ if score < 55:
407
+ if (churn > 0.5 or errors) and verification < 55:
408
+ nudge, col = "drift - step in", RED
409
+ elif autonomy >= 65 and verification >= 45 and churn <= 0.3 and not errors:
410
+ label, nudge, col = "monitoring", "agent on track", CYAN
411
+ if long_session and fatigue >= 70 and score < 70:
412
+ nudge = "rest soon"
413
+ col = RED if score < 45 else YELLOW
414
+ return label, nudge, glyph, col
415
+
416
+
417
+ def present_sample(row):
418
+ """Rebuild the renderable attention dict from a persisted cognition_samples row
419
+ (the v2 read path). Reads the stored composite + the 5 driver columns and
420
+ re-derives label/nudge/glyph via the same classify, so the HUD bar and the
421
+ dashboard agree by construction. Falls back gracefully on partial rows."""
422
+ if not row:
423
+ return {"ok": False}
424
+ score = row.get("attention_score")
425
+ if score is None:
426
+ return {"ok": False}
427
+ try:
428
+ score = int(round(float(score)))
429
+ except (TypeError, ValueError):
430
+ return {"ok": False}
431
+ drivers = {}
432
+ for d in DRIVERS:
433
+ v = row.get("attention_" + d)
434
+ if isinstance(v, (int, float)):
435
+ drivers[d] = float(v)
436
+ trend = row.get("attention_trend")
437
+ trend = float(trend) if isinstance(trend, (int, float)) else 0.0
438
+ context = {
439
+ "long_session": bool(row.get("long_session")),
440
+ "churn": row.get("churn", 0.0),
441
+ "errors": bool(row.get("errors")),
442
+ }
443
+ label, nudge, glyph, color = classify(score, trend, drivers, context)
444
+ top = None
445
+ if drivers:
446
+ top = max(drivers.items(), key=lambda kv: abs(kv[1] - 50.0))[0]
447
+ return {
448
+ "ok": True,
449
+ "score": score,
450
+ "drivers": drivers,
451
+ "top_driver": top,
452
+ "trend": trend,
453
+ "label": label,
454
+ "nudge": nudge,
455
+ "glyph": glyph,
456
+ "color": color,
457
+ }