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.
- zeno_adapters/__init__.py +17 -0
- zeno_adapters/_common.py +38 -0
- zeno_adapters/anthropic.py +68 -0
- zeno_adapters/claude_code.py +101 -0
- zeno_adapters/crewai.py +92 -0
- zeno_adapters/langgraph.py +49 -0
- zeno_adapters/openai.py +108 -0
- zeno_cli/__init__.py +1 -0
- zeno_cli/_hooks/cc_bridge.py +1016 -0
- zeno_cli/doctor.py +535 -0
- zeno_cli/hook_install.py +269 -0
- zeno_cli/hud/__init__.py +1 -0
- zeno_cli/hud/hud_install.py +652 -0
- zeno_cli/hud/zeno_attention.py +288 -0
- zeno_cli/hud/zeno_cognition.py +457 -0
- zeno_cli/hud/zeno_hud.py +496 -0
- zeno_cli/interview_invites.py +342 -0
- zeno_cli/login.py +241 -0
- zeno_cli/main.py +2534 -0
- zeno_cli/onboard.py +206 -0
- zeno_cli/outreach.py +456 -0
- zeno_cli/version.py +67 -0
- zeno_cli-0.3.4.dist-info/METADATA +161 -0
- zeno_cli-0.3.4.dist-info/RECORD +69 -0
- zeno_cli-0.3.4.dist-info/WHEEL +4 -0
- zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
- zeno_core/__init__.py +67 -0
- zeno_core/analytics.py +193 -0
- zeno_core/rtlx_s.py +460 -0
- zeno_core/streak.py +178 -0
- zeno_core/tlx_s.py +192 -0
- zeno_sdk/__init__.py +6 -0
- zeno_sdk/_generated/__init__.py +6 -0
- zeno_sdk/_generated/client.py +819 -0
- zeno_sdk/_migrations/alembic/env.py +33 -0
- zeno_sdk/_migrations/alembic/script.py.mako +18 -0
- zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
- zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
- zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
- zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
- zeno_sdk/_migrations/alembic.ini +35 -0
- zeno_sdk/_runtime.py +12 -0
- zeno_sdk/adapters/__init__.py +15 -0
- zeno_sdk/adapters/anthropic.py +5 -0
- zeno_sdk/adapters/claude_code.py +5 -0
- zeno_sdk/adapters/crewai.py +5 -0
- zeno_sdk/adapters/langgraph.py +5 -0
- zeno_sdk/adapters/openai.py +5 -0
- zeno_sdk/auth.py +25 -0
- zeno_sdk/client.py +87 -0
- zeno_sdk/config.py +61 -0
- zeno_sdk/daemon.py +72 -0
- zeno_sdk/privacy.py +46 -0
- zeno_sdk/session.py +179 -0
- zeno_sdk/storage.py +487 -0
- zeno_sdk/types/__init__.py +121 -0
- zeno_session_intel/__init__.py +19 -0
- zeno_session_intel/analytics.py +588 -0
- zeno_session_intel/compression.py +123 -0
- zeno_session_intel/ingest.py +376 -0
- zeno_session_intel/model.py +129 -0
- zeno_session_intel/parsers/__init__.py +31 -0
- zeno_session_intel/parsers/claude_code.py +169 -0
- zeno_session_intel/parsers/codex.py +265 -0
- zeno_session_intel/parsers/cursor.py +198 -0
- zeno_session_intel/prices.py +281 -0
- zeno_session_intel/schema.py +277 -0
- zeno_session_intel/signals.py +319 -0
- 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
|
+
}
|