Spaces:
Running
Home feed v2: 15-dimension evidence engine + trigger-detection + interleaved feed
Browse filesReplaces the static 9-signal/5-card-type Home feed with a real evidence-gated
system: 15 tracked dimensions (was 9, plus 3 fold-in sub-signals), a new
categorical evidence path for signals whose mean sits near zero (pacing/energy
arc), and a trigger-detection engine that diffs each session's evidence
against persisted state to fire one of 5 event types (first-time-steady,
drift, recurring, context-shift, single-session anomaly) with a shared
2-session cooldown. Home now interleaves an unconditional per-session recap
card with these frozen, dated dimension events instead of recomputing static
cards from live evidence on every read.
Also reformulates 5 signal computations (curiosity as a rate, room-wide
interruptions, a 4-input reweighted drive score, a building-on-others
denominator fix, and fixed-window vocabulary richness to remove a
session-length confound), and adds two new Supabase tables
(signal_evidence_state, dimension_events) to support the trigger engine.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- backend/main.py +85 -35
- backend/pipeline/evidence_gate.py +177 -31
- backend/pipeline/home_feed.py +52 -139
- backend/pipeline/insight_generator.py +30 -14
- backend/pipeline/portrait_synthesizer.py +41 -22
- backend/pipeline/signal_extractor.py +100 -24
- backend/pipeline/trigger_detector.py +309 -0
- frontend/src/components/HomeView.jsx +107 -64
|
@@ -37,8 +37,11 @@ from pipeline.dimension_scorer import DimensionScorer
|
|
| 37 |
from pipeline.voiceprint import VoiceprintMatcher
|
| 38 |
from pipeline.context_detector import ContextDetector
|
| 39 |
from pipeline.portrait_synthesizer import PortraitSynthesizer
|
| 40 |
-
from pipeline.evidence_gate import
|
|
|
|
|
|
|
| 41 |
from pipeline import home_feed
|
|
|
|
| 42 |
from pipeline.llm_utils import extract_text
|
| 43 |
from anthropic import Anthropic
|
| 44 |
from db.database import supabase_admin
|
|
@@ -203,15 +206,20 @@ def _compute_profile_evidence(parsed: list) -> dict:
|
|
| 203 |
values_by_signal = _signal_values(sessions)
|
| 204 |
result = {}
|
| 205 |
for signal_key, values in values_by_signal.items():
|
| 206 |
-
ev =
|
|
|
|
| 207 |
non_none = [v for v in values if v is not None] # chronological, oldest→newest
|
| 208 |
-
# recent-vs-established
|
| 209 |
-
#
|
| 210 |
-
#
|
| 211 |
-
#
|
| 212 |
-
#
|
| 213 |
-
#
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
recent = non_none[-3:]
|
| 216 |
ev["recent_mean"] = round(float(np.mean(recent)), 3)
|
| 217 |
ev["shift_pct"] = (
|
|
@@ -234,8 +242,28 @@ def _compute_profile_evidence(parsed: list) -> dict:
|
|
| 234 |
return {"overall": overall, "by_context": by_context}
|
| 235 |
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
def _get_or_synthesize_portrait(user_id: str, session_count: int, evidence: dict,
|
| 238 |
-
blind_spots: list) -> dict:
|
| 239 |
"""Evidence-based replacement for the old dimension-scoring personality
|
| 240 |
synthesis (retired). Reuses the user_profiles.personality_json /
|
| 241 |
session_count_at_synthesis columns (repurposed, different shape — no schema
|
|
@@ -261,7 +289,8 @@ def _get_or_synthesize_portrait(user_id: str, session_count: int, evidence: dict
|
|
| 261 |
except Exception:
|
| 262 |
pass
|
| 263 |
|
| 264 |
-
|
|
|
|
| 265 |
_portrait_cache[cache_key] = portrait
|
| 266 |
|
| 267 |
try:
|
|
@@ -1044,6 +1073,19 @@ def _run_finalize_job(session_id, confirmed_speaker):
|
|
| 1044 |
)
|
| 1045 |
logger.info("[%s] ✓ Session saved to Supabase", _sid(session_id))
|
| 1046 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
# Trigger background consolidation if user has enough sessions
|
| 1048 |
try:
|
| 1049 |
count_res = supabase_admin.table("sessions").select(
|
|
@@ -1350,23 +1392,30 @@ def get_profile(user_id: str = Depends(get_current_user)):
|
|
| 1350 |
# numeric dimension-scoring personality synthesis entirely — no invented
|
| 1351 |
# dimensions, no 0-100 scores, no trait labels.
|
| 1352 |
evidence = _compute_profile_evidence(parsed)
|
| 1353 |
-
portrait_llm = _get_or_synthesize_portrait(user_id, n, evidence, blind_spots)
|
| 1354 |
llm_notes_by_signal = {s["signal_key"]: s for s in portrait_llm.get("signals", [])}
|
| 1355 |
llm_context_notes = {s["signal_key"]: s["note"] for s in portrait_llm.get("context_shifts", [])}
|
| 1356 |
|
| 1357 |
steady_signals = []
|
| 1358 |
still_forming = []
|
| 1359 |
for signal_key, ev in evidence["overall"].items():
|
| 1360 |
-
|
|
|
|
| 1361 |
if ev["is_steady"]:
|
| 1362 |
llm = llm_notes_by_signal.get(signal_key, {})
|
| 1363 |
steady_signals.append({
|
| 1364 |
"signal_key": signal_key,
|
| 1365 |
"label": label,
|
| 1366 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1367 |
"sample_count": ev["sample_count"],
|
| 1368 |
-
"recent_mean": ev
|
| 1369 |
-
"shift_pct": ev
|
| 1370 |
"framing": llm.get("framing", "observation"),
|
| 1371 |
"note": llm.get("note", ""),
|
| 1372 |
})
|
|
@@ -1383,8 +1432,10 @@ def get_profile(user_id: str = Depends(get_current_user)):
|
|
| 1383 |
|
| 1384 |
how_you_shift_by_context = []
|
| 1385 |
for signal_key, note in llm_context_notes.items():
|
|
|
|
|
|
|
| 1386 |
by_ctx = {
|
| 1387 |
-
ctx: data[signal_key][
|
| 1388 |
for ctx, data in evidence["by_context"].items()
|
| 1389 |
if data.get(signal_key, {}).get("is_steady")
|
| 1390 |
}
|
|
@@ -1428,31 +1479,30 @@ def _get_dismissed_card_keys(user_id: str) -> set:
|
|
| 1428 |
|
| 1429 |
@app.get("/api/home")
|
| 1430 |
def get_home(user_id: str = Depends(get_current_user)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1431 |
parsed = _fetch_and_parse_sessions(user_id)
|
| 1432 |
if len(parsed) < 1:
|
| 1433 |
return {"insufficient_data": True, "session_count": 0, "cards": []}
|
| 1434 |
|
| 1435 |
-
n = len(parsed)
|
| 1436 |
-
recorded_contexts = set(p["context"] for p in parsed)
|
| 1437 |
-
blind_spots = _compute_blind_spots(recorded_contexts)
|
| 1438 |
-
|
| 1439 |
-
# Same evidence + portrait computation /api/profile uses — same cache key,
|
| 1440 |
-
# so viewing Home and You back-to-back costs zero extra LLM calls either way.
|
| 1441 |
-
evidence = _compute_profile_evidence(parsed)
|
| 1442 |
-
portrait_llm = _get_or_synthesize_portrait(user_id, n, evidence, blind_spots)
|
| 1443 |
-
llm_notes_by_signal = {s["signal_key"]: s for s in portrait_llm.get("signals", [])}
|
| 1444 |
-
|
| 1445 |
dismissed = _get_dismissed_card_keys(user_id)
|
| 1446 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1447 |
cards = []
|
| 1448 |
-
cards += home_feed.
|
| 1449 |
-
cards += home_feed.
|
| 1450 |
-
cards
|
| 1451 |
-
cards += home_feed.build_progress_cards(evidence, dismissed)
|
| 1452 |
-
cards += home_feed.build_still_forming_cards(evidence, dismissed)
|
| 1453 |
-
cards += home_feed.build_session_observation_card(parsed, dismissed)
|
| 1454 |
|
| 1455 |
-
return {"insufficient_data": False, "session_count":
|
| 1456 |
|
| 1457 |
|
| 1458 |
@app.post("/api/home/dismiss")
|
|
@@ -1718,7 +1768,7 @@ def _get_context_evidence(user_id: str, context: str) -> dict:
|
|
| 1718 |
values = [extract_value(signal_key, sig) for sig in all_signals]
|
| 1719 |
except (KeyError, TypeError):
|
| 1720 |
values = []
|
| 1721 |
-
evidence[signal_key] =
|
| 1722 |
|
| 1723 |
return {"context": context, "session_count": len(context_sessions), "signals": evidence}
|
| 1724 |
|
|
|
|
| 37 |
from pipeline.voiceprint import VoiceprintMatcher
|
| 38 |
from pipeline.context_detector import ContextDetector
|
| 39 |
from pipeline.portrait_synthesizer import PortraitSynthesizer
|
| 40 |
+
from pipeline.evidence_gate import (
|
| 41 |
+
SIGNAL_EVIDENCE_CONFIG, SUB_SIGNAL_EVIDENCE_CONFIG, compute_evidence, extract_value
|
| 42 |
+
)
|
| 43 |
from pipeline import home_feed
|
| 44 |
+
from pipeline.trigger_detector import run_trigger_detection
|
| 45 |
from pipeline.llm_utils import extract_text
|
| 46 |
from anthropic import Anthropic
|
| 47 |
from db.database import supabase_admin
|
|
|
|
| 206 |
values_by_signal = _signal_values(sessions)
|
| 207 |
result = {}
|
| 208 |
for signal_key, values in values_by_signal.items():
|
| 209 |
+
ev = compute_evidence(signal_key, values) # filters None internally; dispatches continuous/categorical
|
| 210 |
+
is_categorical = SIGNAL_EVIDENCE_CONFIG[signal_key]["kind"] == "categorical"
|
| 211 |
non_none = [v for v in values if v is not None] # chronological, oldest→newest
|
| 212 |
+
# recent-vs-established shift is only meaningful for a continuous
|
| 213 |
+
# signal (needs a numeric mean) — categorical dims (pacing_arc,
|
| 214 |
+
# energy_arc) express "progress" via drift/context-shift events
|
| 215 |
+
# instead, not a shift_pct card. Also only computed here for a
|
| 216 |
+
# SINGLE context (see by_context below) — computing it on the
|
| 217 |
+
# cross-context pool is misleading: a run of e.g. social sessions
|
| 218 |
+
# can drag the "recent" average down for reasons that have nothing
|
| 219 |
+
# to do with a genuine behavioral shift, just a different
|
| 220 |
+
# conversation type happening recently. Self-relative framing has
|
| 221 |
+
# to stay within one context.
|
| 222 |
+
if not is_categorical and compute_shift and ev["is_steady"] and len(non_none) >= 3:
|
| 223 |
recent = non_none[-3:]
|
| 224 |
ev["recent_mean"] = round(float(np.mean(recent)), 3)
|
| 225 |
ev["shift_pct"] = (
|
|
|
|
| 242 |
return {"overall": overall, "by_context": by_context}
|
| 243 |
|
| 244 |
|
| 245 |
+
def _compute_sub_signal_evidence(parsed: list) -> dict:
|
| 246 |
+
"""Evidence for the 3 fold-in sub-signals (question_pickup, gets_interrupted,
|
| 247 |
+
long_turn_rate) — pooled only, no by_context split (subs don't get their own
|
| 248 |
+
context-shift treatment, see evidence_gate.SUB_SIGNAL_EVIDENCE_CONFIG). Kept
|
| 249 |
+
separate from _compute_profile_evidence's main 15-dimension dicts so the
|
| 250 |
+
You-page steady/still-forming lists and profile_strength_pct stay scoped to
|
| 251 |
+
the real dimensions — this is only consumed for sub-note gating (e.g.
|
| 252 |
+
portrait_synthesizer's "how it may land" section)."""
|
| 253 |
+
result = {}
|
| 254 |
+
for sub_key in SUB_SIGNAL_EVIDENCE_CONFIG:
|
| 255 |
+
values = []
|
| 256 |
+
for p in parsed:
|
| 257 |
+
try:
|
| 258 |
+
values.append(extract_value(sub_key, p["sig"]))
|
| 259 |
+
except (KeyError, TypeError):
|
| 260 |
+
pass
|
| 261 |
+
result[sub_key] = compute_evidence(sub_key, values, SUB_SIGNAL_EVIDENCE_CONFIG)
|
| 262 |
+
return result
|
| 263 |
+
|
| 264 |
+
|
| 265 |
def _get_or_synthesize_portrait(user_id: str, session_count: int, evidence: dict,
|
| 266 |
+
blind_spots: list, parsed: list) -> dict:
|
| 267 |
"""Evidence-based replacement for the old dimension-scoring personality
|
| 268 |
synthesis (retired). Reuses the user_profiles.personality_json /
|
| 269 |
session_count_at_synthesis columns (repurposed, different shape — no schema
|
|
|
|
| 289 |
except Exception:
|
| 290 |
pass
|
| 291 |
|
| 292 |
+
sub_evidence = _compute_sub_signal_evidence(parsed)
|
| 293 |
+
portrait = portrait_synth.synthesize(evidence, blind_spots, session_count, sub_evidence)
|
| 294 |
_portrait_cache[cache_key] = portrait
|
| 295 |
|
| 296 |
try:
|
|
|
|
| 1073 |
)
|
| 1074 |
logger.info("[%s] ✓ Session saved to Supabase", _sid(session_id))
|
| 1075 |
|
| 1076 |
+
# Home feed dimension-maturation events — diffs this session's evidence
|
| 1077 |
+
# against persisted state and writes any newly fired event. Wrapped
|
| 1078 |
+
# defensively (same pattern as the consolidation trigger below) so a
|
| 1079 |
+
# bug here can never break finalize itself.
|
| 1080 |
+
try:
|
| 1081 |
+
fired_events = run_trigger_detection(user_id, session_id, primary_context)
|
| 1082 |
+
if fired_events:
|
| 1083 |
+
logger.info("[%s] ✓ %d dimension event(s) fired: %s", _sid(session_id),
|
| 1084 |
+
len(fired_events), [e["trigger_type"] for e in fired_events])
|
| 1085 |
+
except Exception:
|
| 1086 |
+
logger.error("[%s] ✕ Trigger detection failed (non-fatal):\n%s",
|
| 1087 |
+
_sid(session_id), _tb.format_exc())
|
| 1088 |
+
|
| 1089 |
# Trigger background consolidation if user has enough sessions
|
| 1090 |
try:
|
| 1091 |
count_res = supabase_admin.table("sessions").select(
|
|
|
|
| 1392 |
# numeric dimension-scoring personality synthesis entirely — no invented
|
| 1393 |
# dimensions, no 0-100 scores, no trait labels.
|
| 1394 |
evidence = _compute_profile_evidence(parsed)
|
| 1395 |
+
portrait_llm = _get_or_synthesize_portrait(user_id, n, evidence, blind_spots, parsed)
|
| 1396 |
llm_notes_by_signal = {s["signal_key"]: s for s in portrait_llm.get("signals", [])}
|
| 1397 |
llm_context_notes = {s["signal_key"]: s["note"] for s in portrait_llm.get("context_shifts", [])}
|
| 1398 |
|
| 1399 |
steady_signals = []
|
| 1400 |
still_forming = []
|
| 1401 |
for signal_key, ev in evidence["overall"].items():
|
| 1402 |
+
cfg = SIGNAL_EVIDENCE_CONFIG[signal_key]
|
| 1403 |
+
label = cfg["label"]
|
| 1404 |
if ev["is_steady"]:
|
| 1405 |
llm = llm_notes_by_signal.get(signal_key, {})
|
| 1406 |
steady_signals.append({
|
| 1407 |
"signal_key": signal_key,
|
| 1408 |
"label": label,
|
| 1409 |
+
"kind": cfg["kind"],
|
| 1410 |
+
# continuous dims populate mean/recent_mean/shift_pct; categorical
|
| 1411 |
+
# dims (pacing_arc, energy_arc) populate mode_label/agreement_ratio
|
| 1412 |
+
# instead — never both, since categorical has no numeric mean.
|
| 1413 |
+
"mean": ev.get("mean"),
|
| 1414 |
+
"mode_label": ev.get("mode_label"),
|
| 1415 |
+
"agreement_ratio": ev.get("agreement_ratio"),
|
| 1416 |
"sample_count": ev["sample_count"],
|
| 1417 |
+
"recent_mean": ev.get("recent_mean"),
|
| 1418 |
+
"shift_pct": ev.get("shift_pct"),
|
| 1419 |
"framing": llm.get("framing", "observation"),
|
| 1420 |
"note": llm.get("note", ""),
|
| 1421 |
})
|
|
|
|
| 1432 |
|
| 1433 |
how_you_shift_by_context = []
|
| 1434 |
for signal_key, note in llm_context_notes.items():
|
| 1435 |
+
is_categorical = SIGNAL_EVIDENCE_CONFIG[signal_key]["kind"] == "categorical"
|
| 1436 |
+
value_field = "mode_label" if is_categorical else "mean"
|
| 1437 |
by_ctx = {
|
| 1438 |
+
ctx: data[signal_key][value_field]
|
| 1439 |
for ctx, data in evidence["by_context"].items()
|
| 1440 |
if data.get(signal_key, {}).get("is_steady")
|
| 1441 |
}
|
|
|
|
| 1479 |
|
| 1480 |
@app.get("/api/home")
|
| 1481 |
def get_home(user_id: str = Depends(get_current_user)):
|
| 1482 |
+
"""v2 Home feed: a single interleaved timeline of session recap cards
|
| 1483 |
+
(unconditional, one per session) and dimension-maturation event cards
|
| 1484 |
+
(fired by pipeline/trigger_detector.py at finalize time), sorted newest
|
| 1485 |
+
first. No longer needs evidence/portrait computation at all — that LLM
|
| 1486 |
+
call only runs when the You page itself is viewed, not on every Home load.
|
| 1487 |
+
The "7 visible + 8th faded + Show more" behavior is a frontend rendering
|
| 1488 |
+
contract over this complete, uncapped list — backend returns everything.
|
| 1489 |
+
"""
|
| 1490 |
parsed = _fetch_and_parse_sessions(user_id)
|
| 1491 |
if len(parsed) < 1:
|
| 1492 |
return {"insufficient_data": True, "session_count": 0, "cards": []}
|
| 1493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1494 |
dismissed = _get_dismissed_card_keys(user_id)
|
| 1495 |
|
| 1496 |
+
events_res = supabase_admin.table("dimension_events").select("*").eq(
|
| 1497 |
+
"user_id", user_id
|
| 1498 |
+
).order("created_at", desc=True).execute()
|
| 1499 |
+
|
| 1500 |
cards = []
|
| 1501 |
+
cards += home_feed.build_session_recap_cards(parsed, dismissed)
|
| 1502 |
+
cards += home_feed.build_dimension_event_cards(events_res.data, dismissed)
|
| 1503 |
+
cards.sort(key=lambda c: c["date"], reverse=True)
|
|
|
|
|
|
|
|
|
|
| 1504 |
|
| 1505 |
+
return {"insufficient_data": False, "session_count": len(parsed), "cards": cards}
|
| 1506 |
|
| 1507 |
|
| 1508 |
@app.post("/api/home/dismiss")
|
|
|
|
| 1768 |
values = [extract_value(signal_key, sig) for sig in all_signals]
|
| 1769 |
except (KeyError, TypeError):
|
| 1770 |
values = []
|
| 1771 |
+
evidence[signal_key] = compute_evidence(signal_key, values)
|
| 1772 |
|
| 1773 |
return {"context": context, "session_count": len(context_sessions), "signals": evidence}
|
| 1774 |
|
|
@@ -1,62 +1,159 @@
|
|
| 1 |
import numpy as np
|
|
|
|
| 2 |
|
| 3 |
-
# Per-signal (min_samples,
|
| 4 |
-
# the reasoning behind each
|
| 5 |
# user data yet; expect to tune once sessions accumulate.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
SIGNAL_EVIDENCE_CONFIG = {
|
| 7 |
"talk_ratio": {
|
| 8 |
-
"
|
|
|
|
| 9 |
"cv_threshold": 0.20,
|
| 10 |
"extract": lambda sig: sig["talk_ratio"]["user_ratio"],
|
| 11 |
"label": "talk-share",
|
| 12 |
},
|
| 13 |
-
"
|
|
|
|
| 14 |
"min_samples": 5,
|
| 15 |
"cv_threshold": 0.35,
|
| 16 |
-
"extract": lambda sig: sig["
|
| 17 |
"label": "curiosity",
|
| 18 |
},
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
"
|
|
|
|
| 24 |
},
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
|
|
|
| 30 |
},
|
| 31 |
"hedging": {
|
|
|
|
| 32 |
"min_samples": 5,
|
| 33 |
"cv_threshold": 0.30,
|
| 34 |
"extract": lambda sig: sig["hedging"]["rate_per_100_words"],
|
| 35 |
"label": "hedging",
|
| 36 |
},
|
| 37 |
"directness": {
|
|
|
|
| 38 |
"min_samples": 5,
|
| 39 |
"cv_threshold": 0.30,
|
| 40 |
"extract": lambda sig: sig["directness"]["rate_per_100_words"],
|
| 41 |
"label": "directness",
|
| 42 |
},
|
| 43 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
"min_samples": 6,
|
| 45 |
"cv_threshold": 0.45,
|
| 46 |
"extract": lambda sig: sig["question_impact"]["pickup_rate"],
|
| 47 |
"label": "question follow-through",
|
|
|
|
|
|
|
| 48 |
},
|
| 49 |
-
"
|
|
|
|
| 50 |
"min_samples": 6,
|
| 51 |
-
"cv_threshold": 0.
|
| 52 |
-
"extract": lambda sig: sig["
|
| 53 |
-
"label": "
|
|
|
|
|
|
|
| 54 |
},
|
| 55 |
-
"
|
|
|
|
| 56 |
"min_samples": 6,
|
| 57 |
"cv_threshold": 0.40,
|
| 58 |
-
"extract": lambda sig: sig["
|
| 59 |
-
"label": "
|
|
|
|
|
|
|
| 60 |
},
|
| 61 |
}
|
| 62 |
|
|
@@ -65,15 +162,23 @@ SIGNAL_EVIDENCE_CONFIG = {
|
|
| 65 |
ROLLING_WINDOW = 10
|
| 66 |
|
| 67 |
|
| 68 |
-
def
|
| 69 |
-
"""
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
Pure function over already-extracted values — doesn't fetch data itself,
|
| 73 |
so it's independently testable regardless of where the values came from.
|
| 74 |
"""
|
| 75 |
-
cfg = SIGNAL_EVIDENCE_CONFIG
|
| 76 |
-
# Some signals (e.g.
|
| 77 |
# (zero questions asked) — None, not a fake 0, so drop it rather than let it
|
| 78 |
# pollute the mean/cv or crash np.mean.
|
| 79 |
historical_values = [v for v in historical_values if v is not None]
|
|
@@ -100,7 +205,48 @@ def compute_signal_evidence(signal_key: str, historical_values: list) -> dict:
|
|
| 100 |
return result
|
| 101 |
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
def extract_value(signal_key: str, signals: dict):
|
| 104 |
-
"""Pull this signal's tracked scalar out of a full `signals` dict (as
|
| 105 |
-
in sessions.signals_json).
|
| 106 |
-
|
|
|
|
|
|
| 1 |
import numpy as np
|
| 2 |
+
from collections import Counter
|
| 3 |
|
| 4 |
+
# Per-signal (min_samples, threshold) — see roadmap/product_decisions memory for
|
| 5 |
+
# the reasoning behind each value. These are starting defaults with no real
|
| 6 |
# user data yet; expect to tune once sessions accumulate.
|
| 7 |
+
#
|
| 8 |
+
# "kind" dispatches which evidence function applies:
|
| 9 |
+
# "continuous" — a numeric rate/ratio, steadiness = coefficient of variation
|
| 10 |
+
# (std/mean) at or below cv_threshold.
|
| 11 |
+
# "categorical" — a string label (e.g. "accelerating"/"stable"/"decelerating"),
|
| 12 |
+
# steadiness = the most common label's share of the rolling
|
| 13 |
+
# window at or above agreement_threshold. Used for the two
|
| 14 |
+
# "arc" signals whose natural magnitude sits near zero for most
|
| 15 |
+
# people, which would make cv unstable (division by ~0).
|
| 16 |
SIGNAL_EVIDENCE_CONFIG = {
|
| 17 |
"talk_ratio": {
|
| 18 |
+
"kind": "continuous",
|
| 19 |
+
"min_samples": 3,
|
| 20 |
"cv_threshold": 0.20,
|
| 21 |
"extract": lambda sig: sig["talk_ratio"]["user_ratio"],
|
| 22 |
"label": "talk-share",
|
| 23 |
},
|
| 24 |
+
"curiosity": {
|
| 25 |
+
"kind": "continuous",
|
| 26 |
"min_samples": 5,
|
| 27 |
"cv_threshold": 0.35,
|
| 28 |
+
"extract": lambda sig: sig["curiosity"]["question_turn_rate_per_100_words"],
|
| 29 |
"label": "curiosity",
|
| 30 |
},
|
| 31 |
+
"turn_taking_assertiveness": {
|
| 32 |
+
"kind": "continuous",
|
| 33 |
+
"min_samples": 4,
|
| 34 |
+
"cv_threshold": 0.30,
|
| 35 |
+
"extract": lambda sig: sig["interruptions"]["user_interrupt_rate_per_10_transitions"],
|
| 36 |
+
"label": "turn-taking assertiveness",
|
| 37 |
},
|
| 38 |
+
"conversational_drive": {
|
| 39 |
+
"kind": "continuous",
|
| 40 |
+
"min_samples": 6,
|
| 41 |
+
"cv_threshold": 0.35,
|
| 42 |
+
"extract": lambda sig: sig["drive_vs_follow"]["drive_score"],
|
| 43 |
+
"label": "conversational drive",
|
| 44 |
},
|
| 45 |
"hedging": {
|
| 46 |
+
"kind": "continuous",
|
| 47 |
"min_samples": 5,
|
| 48 |
"cv_threshold": 0.30,
|
| 49 |
"extract": lambda sig: sig["hedging"]["rate_per_100_words"],
|
| 50 |
"label": "hedging",
|
| 51 |
},
|
| 52 |
"directness": {
|
| 53 |
+
"kind": "continuous",
|
| 54 |
"min_samples": 5,
|
| 55 |
"cv_threshold": 0.30,
|
| 56 |
"extract": lambda sig: sig["directness"]["rate_per_100_words"],
|
| 57 |
"label": "directness",
|
| 58 |
},
|
| 59 |
+
"building_on_others": {
|
| 60 |
+
"kind": "continuous",
|
| 61 |
+
"min_samples": 6,
|
| 62 |
+
"cv_threshold": 0.40,
|
| 63 |
+
"extract": lambda sig: sig["building_on_others"]["building_on_rate"],
|
| 64 |
+
"label": "building on others",
|
| 65 |
+
},
|
| 66 |
+
"pace": {
|
| 67 |
+
"kind": "continuous",
|
| 68 |
+
"min_samples": 3,
|
| 69 |
+
"cv_threshold": 0.15,
|
| 70 |
+
"extract": lambda sig: sig["speech_rate"]["overall_wpm"],
|
| 71 |
+
"label": "pace",
|
| 72 |
+
},
|
| 73 |
+
"pacing_arc": {
|
| 74 |
+
"kind": "categorical",
|
| 75 |
+
"min_samples": 5,
|
| 76 |
+
"agreement_threshold": 0.60,
|
| 77 |
+
"extract": lambda sig: sig["speech_acceleration"]["trend"],
|
| 78 |
+
"label": "pacing arc",
|
| 79 |
+
},
|
| 80 |
+
"vocal_expressiveness": {
|
| 81 |
+
"kind": "continuous",
|
| 82 |
+
"min_samples": 3,
|
| 83 |
+
"cv_threshold": 0.25,
|
| 84 |
+
"extract": lambda sig: sig["pitch_features"]["std_hz"],
|
| 85 |
+
"label": "vocal expressiveness",
|
| 86 |
+
},
|
| 87 |
+
"energy_arc": {
|
| 88 |
+
"kind": "categorical",
|
| 89 |
+
"min_samples": 5,
|
| 90 |
+
"agreement_threshold": 0.60,
|
| 91 |
+
"extract": lambda sig: sig["vocal_energy"]["trend"],
|
| 92 |
+
"label": "energy arc",
|
| 93 |
+
},
|
| 94 |
+
"turn_length": {
|
| 95 |
+
"kind": "continuous",
|
| 96 |
+
"min_samples": 4,
|
| 97 |
+
"cv_threshold": 0.25,
|
| 98 |
+
"extract": lambda sig: sig["monologue"]["avg_turn_length_s"],
|
| 99 |
+
"label": "turn length",
|
| 100 |
+
},
|
| 101 |
+
"vocabulary_richness": {
|
| 102 |
+
"kind": "continuous",
|
| 103 |
+
"min_samples": 5,
|
| 104 |
+
"cv_threshold": 0.20,
|
| 105 |
+
"extract": lambda sig: sig["vocabulary_richness"]["type_token_ratio"],
|
| 106 |
+
"label": "vocabulary richness",
|
| 107 |
+
},
|
| 108 |
+
"fillers": {
|
| 109 |
+
"kind": "continuous",
|
| 110 |
+
"min_samples": 5,
|
| 111 |
+
"cv_threshold": 0.35,
|
| 112 |
+
"extract": lambda sig: sig["filler_words"]["rate_per_100_words"],
|
| 113 |
+
"label": "fillers",
|
| 114 |
+
},
|
| 115 |
+
"response_latency": {
|
| 116 |
+
"kind": "continuous",
|
| 117 |
+
"min_samples": 5,
|
| 118 |
+
"cv_threshold": 0.40,
|
| 119 |
+
"extract": lambda sig: sig["pauses"]["response_latency"]["mean_s"],
|
| 120 |
+
"label": "pauses",
|
| 121 |
+
},
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
# Sub-signals: fold into a parent dimension's card as a bonus note rather than
|
| 125 |
+
# getting their own dimension slot. Own evidence tracking (looser thresholds —
|
| 126 |
+
# each depends more on the other person's behavior than the user's own), but
|
| 127 |
+
# only ever fire "first_time_steady" — no drift/recurring/context_shift/anomaly
|
| 128 |
+
# of their own. Kept out of SIGNAL_EVIDENCE_CONFIG so profile_strength_pct and
|
| 129 |
+
# the You-page steady/still-forming lists stay scoped to the 15 real dimensions.
|
| 130 |
+
SUB_SIGNAL_EVIDENCE_CONFIG = {
|
| 131 |
+
"question_pickup": {
|
| 132 |
+
"kind": "continuous",
|
| 133 |
"min_samples": 6,
|
| 134 |
"cv_threshold": 0.45,
|
| 135 |
"extract": lambda sig: sig["question_impact"]["pickup_rate"],
|
| 136 |
"label": "question follow-through",
|
| 137 |
+
"parent": "curiosity",
|
| 138 |
+
"allowed_triggers": ["first_time_steady"],
|
| 139 |
},
|
| 140 |
+
"gets_interrupted": {
|
| 141 |
+
"kind": "continuous",
|
| 142 |
"min_samples": 6,
|
| 143 |
+
"cv_threshold": 0.40,
|
| 144 |
+
"extract": lambda sig: sig["interruptions"]["user_was_interrupted_rate_per_10_transitions"],
|
| 145 |
+
"label": "gets interrupted",
|
| 146 |
+
"parent": "turn_taking_assertiveness",
|
| 147 |
+
"allowed_triggers": ["first_time_steady"],
|
| 148 |
},
|
| 149 |
+
"long_turn_rate": {
|
| 150 |
+
"kind": "continuous",
|
| 151 |
"min_samples": 6,
|
| 152 |
"cv_threshold": 0.40,
|
| 153 |
+
"extract": lambda sig: sig["monologue"]["long_turn_rate"],
|
| 154 |
+
"label": "long speaking stretches",
|
| 155 |
+
"parent": "turn_length",
|
| 156 |
+
"allowed_triggers": ["first_time_steady"],
|
| 157 |
},
|
| 158 |
}
|
| 159 |
|
|
|
|
| 162 |
ROLLING_WINDOW = 10
|
| 163 |
|
| 164 |
|
| 165 |
+
def _cfg_for(signal_key: str) -> dict:
|
| 166 |
+
"""Look a key up across both the main dimension config and the sub-signal
|
| 167 |
+
config, so callers don't need to know which dict a given key lives in."""
|
| 168 |
+
if signal_key in SIGNAL_EVIDENCE_CONFIG:
|
| 169 |
+
return SIGNAL_EVIDENCE_CONFIG[signal_key]
|
| 170 |
+
return SUB_SIGNAL_EVIDENCE_CONFIG[signal_key]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def compute_signal_evidence(signal_key: str, historical_values: list, config: dict = None) -> dict:
|
| 174 |
+
"""Given all past values of one CONTINUOUS signal (oldest→newest) for a
|
| 175 |
+
user+context, return an evidence summary: sample_count, mean, cv, is_steady.
|
| 176 |
|
| 177 |
Pure function over already-extracted values — doesn't fetch data itself,
|
| 178 |
so it's independently testable regardless of where the values came from.
|
| 179 |
"""
|
| 180 |
+
cfg = (config or SIGNAL_EVIDENCE_CONFIG).get(signal_key) or SUB_SIGNAL_EVIDENCE_CONFIG.get(signal_key)
|
| 181 |
+
# Some signals (e.g. question_pickup) legitimately have no value for a session
|
| 182 |
# (zero questions asked) — None, not a fake 0, so drop it rather than let it
|
| 183 |
# pollute the mean/cv or crash np.mean.
|
| 184 |
historical_values = [v for v in historical_values if v is not None]
|
|
|
|
| 205 |
return result
|
| 206 |
|
| 207 |
|
| 208 |
+
def compute_categorical_evidence(signal_key: str, historical_labels: list, config: dict = None) -> dict:
|
| 209 |
+
"""Given all past CATEGORICAL labels (oldest→newest) for a user+context,
|
| 210 |
+
return an evidence summary based on majority agreement within the rolling
|
| 211 |
+
window, not variance — see the "kind" docstring above `SIGNAL_EVIDENCE_CONFIG`
|
| 212 |
+
for why (a magnitude-based cv is unstable for signals whose natural mean
|
| 213 |
+
sits near zero, e.g. pacing/energy arcs for people with no strong drift).
|
| 214 |
+
"""
|
| 215 |
+
cfg = (config or SIGNAL_EVIDENCE_CONFIG).get(signal_key) or SUB_SIGNAL_EVIDENCE_CONFIG.get(signal_key)
|
| 216 |
+
labels = [v for v in historical_labels if v and v != "insufficient_data"]
|
| 217 |
+
n = len(labels)
|
| 218 |
+
result = {
|
| 219 |
+
"signal": signal_key,
|
| 220 |
+
"sample_count": n,
|
| 221 |
+
"min_samples_required": cfg["min_samples"],
|
| 222 |
+
"is_steady": False,
|
| 223 |
+
"mode_label": None,
|
| 224 |
+
"agreement_ratio": None,
|
| 225 |
+
}
|
| 226 |
+
if n < cfg["min_samples"]:
|
| 227 |
+
return result
|
| 228 |
+
|
| 229 |
+
window = labels[-ROLLING_WINDOW:]
|
| 230 |
+
mode_label, mode_count = Counter(window).most_common(1)[0]
|
| 231 |
+
agreement = mode_count / len(window)
|
| 232 |
+
result["mode_label"] = mode_label
|
| 233 |
+
result["agreement_ratio"] = round(agreement, 3)
|
| 234 |
+
result["is_steady"] = agreement >= cfg["agreement_threshold"]
|
| 235 |
+
return result
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def compute_evidence(signal_key: str, historical_values: list, config: dict = None) -> dict:
|
| 239 |
+
"""Dispatches to the right evidence function based on the signal's "kind".
|
| 240 |
+
Every caller should go through this rather than calling either function
|
| 241 |
+
directly, so categorical dimensions plug in transparently everywhere."""
|
| 242 |
+
cfg = _cfg_for(signal_key) if config is None else config.get(signal_key, _cfg_for(signal_key))
|
| 243 |
+
if cfg["kind"] == "categorical":
|
| 244 |
+
return compute_categorical_evidence(signal_key, historical_values, config)
|
| 245 |
+
return compute_signal_evidence(signal_key, historical_values, config)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
def extract_value(signal_key: str, signals: dict):
|
| 249 |
+
"""Pull this signal's tracked scalar/label out of a full `signals` dict (as
|
| 250 |
+
stored in sessions.signals_json). Checks both the main dimension config and
|
| 251 |
+
the sub-signal config."""
|
| 252 |
+
return _cfg_for(signal_key)["extract"](signals)
|
|
@@ -1,136 +1,76 @@
|
|
| 1 |
-
"""Home feed card builders —
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
LLM calls except the how_it_may_land note, which reuses portrait_synthesizer's
|
| 5 |
-
existing single LLM round-trip (see PortraitSynthesizer.synthesize).
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from pipeline.evidence_gate import SIGNAL_EVIDENCE_CONFIG
|
| 14 |
-
from pipeline.portrait_synthesizer import _format_mean
|
| 15 |
-
|
| 16 |
-
# Shift magnitude below which a change isn't worth surfacing as a progress
|
| 17 |
-
# card — same threshold the old /api/trends widget used (main.py's _trend()).
|
| 18 |
-
_PROGRESS_SHIFT_THRESHOLD_PCT = 15
|
| 19 |
-
# How close to evidence-steady a signal needs to be to show up as "still forming"
|
| 20 |
-
# rather than being silently omitted — avoids listing every not-yet-steady signal.
|
| 21 |
-
_STILL_FORMING_PROXIMITY = 3
|
| 22 |
-
_STILL_FORMING_CAP = 2
|
| 23 |
|
| 24 |
|
| 25 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
cards = []
|
| 27 |
-
for
|
| 28 |
-
|
| 29 |
-
continue
|
| 30 |
-
llm = llm_notes_by_signal.get(signal_key, {})
|
| 31 |
-
if llm.get("framing") != "strength":
|
| 32 |
-
continue
|
| 33 |
-
card_key = f"strength:{signal_key}"
|
| 34 |
if card_key in dismissed:
|
| 35 |
continue
|
|
|
|
| 36 |
cards.append({
|
| 37 |
-
"type": "
|
| 38 |
"card_key": card_key,
|
| 39 |
-
"
|
| 40 |
-
"
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
|
|
|
|
|
|
| 44 |
})
|
| 45 |
return cards
|
| 46 |
|
| 47 |
|
| 48 |
-
def
|
| 49 |
-
"""
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
| 52 |
cards = []
|
| 53 |
-
for
|
| 54 |
-
|
| 55 |
-
continue
|
| 56 |
-
llm = llm_notes_by_signal.get(signal_key, {})
|
| 57 |
-
if llm.get("framing") not in ("growth_area", "observation"):
|
| 58 |
-
continue
|
| 59 |
-
card_key = f"observation:{signal_key}"
|
| 60 |
if card_key in dismissed:
|
| 61 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
cards.append({
|
| 63 |
-
"type": "
|
| 64 |
-
"card_key": card_key,
|
| 65 |
-
"signal_key": signal_key,
|
| 66 |
-
"label": SIGNAL_EVIDENCE_CONFIG[signal_key]["label"],
|
| 67 |
-
"framing": llm.get("framing"),
|
| 68 |
-
"note": llm.get("note", ""),
|
| 69 |
-
"mean": ev["mean"],
|
| 70 |
-
"sample_count": ev["sample_count"],
|
| 71 |
-
})
|
| 72 |
-
return cards
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def build_still_forming_cards(evidence: dict, dismissed: set) -> list:
|
| 76 |
-
candidates = []
|
| 77 |
-
for signal_key, ev in evidence.get("overall", {}).items():
|
| 78 |
-
if ev.get("is_steady"):
|
| 79 |
-
continue
|
| 80 |
-
remaining = ev["min_samples_required"] - ev["sample_count"]
|
| 81 |
-
# remaining <= 0 means the sample floor is already met but the signal is
|
| 82 |
-
# still too variable (high CV) to call steady — that's not "forming",
|
| 83 |
-
# more sessions alone won't fix it. Say nothing rather than mislabel it
|
| 84 |
-
# as approaching-steady forever (CLAUDE.md rule #7: silence is allowed).
|
| 85 |
-
if remaining <= 0 or remaining > _STILL_FORMING_PROXIMITY:
|
| 86 |
-
continue
|
| 87 |
-
card_key = f"still_forming:{signal_key}"
|
| 88 |
-
if card_key in dismissed:
|
| 89 |
-
continue
|
| 90 |
-
candidates.append({
|
| 91 |
-
"type": "still_forming",
|
| 92 |
"card_key": card_key,
|
| 93 |
-
"
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
"
|
| 97 |
-
"
|
|
|
|
|
|
|
|
|
|
| 98 |
})
|
| 99 |
-
candidates.sort(key=lambda c: c["_remaining"])
|
| 100 |
-
for c in candidates:
|
| 101 |
-
del c["_remaining"]
|
| 102 |
-
return candidates[:_STILL_FORMING_CAP]
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def build_progress_cards(evidence: dict, dismissed: set) -> list:
|
| 106 |
-
"""Scoped to by_context evidence only — recent-vs-established shift is
|
| 107 |
-
only meaningful within a single context (see _compute_profile_evidence's
|
| 108 |
-
compute_shift docstring for the cross-context contamination this avoids)."""
|
| 109 |
-
cards = []
|
| 110 |
-
for context, signals in evidence.get("by_context", {}).items():
|
| 111 |
-
for signal_key, ev in signals.items():
|
| 112 |
-
if not ev.get("is_steady") or ev.get("shift_pct") is None:
|
| 113 |
-
continue
|
| 114 |
-
if abs(ev["shift_pct"]) < _PROGRESS_SHIFT_THRESHOLD_PCT:
|
| 115 |
-
continue
|
| 116 |
-
direction = "up" if ev["shift_pct"] > 0 else "down"
|
| 117 |
-
card_key = f"progress:{signal_key}:{direction}"
|
| 118 |
-
if card_key in dismissed:
|
| 119 |
-
continue
|
| 120 |
-
label = SIGNAL_EVIDENCE_CONFIG[signal_key]["label"]
|
| 121 |
-
cards.append({
|
| 122 |
-
"type": "progress",
|
| 123 |
-
"card_key": card_key,
|
| 124 |
-
"signal_key": signal_key,
|
| 125 |
-
"label": label,
|
| 126 |
-
"context": context,
|
| 127 |
-
"direction": direction,
|
| 128 |
-
"note": (
|
| 129 |
-
f"Your {label} has shifted from {_format_mean(signal_key, ev['mean'])} "
|
| 130 |
-
f"to {_format_mean(signal_key, ev['recent_mean'])} over your last few "
|
| 131 |
-
f"{context.replace('_', ' ')} sessions."
|
| 132 |
-
),
|
| 133 |
-
})
|
| 134 |
return cards
|
| 135 |
|
| 136 |
|
|
@@ -151,30 +91,3 @@ def build_how_it_may_land_cards(portrait_llm: dict, dismissed: set) -> list:
|
|
| 151 |
"note": entry.get("note", ""),
|
| 152 |
})
|
| 153 |
return cards
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
def build_session_observation_card(parsed: list, dismissed: set) -> list:
|
| 157 |
-
"""Zero-LLM — surfaces the most recent session's top observation, already
|
| 158 |
-
generated by insight_generator.py. Self-expires: a new session produces a
|
| 159 |
-
new session_id, so this card never needs its own dismissal cleanup."""
|
| 160 |
-
if not parsed:
|
| 161 |
-
return []
|
| 162 |
-
latest = parsed[-1]
|
| 163 |
-
observations = latest["ins"].get("observations", [])
|
| 164 |
-
if not observations:
|
| 165 |
-
return []
|
| 166 |
-
obs = observations[0]
|
| 167 |
-
signal = obs.get("signal", "")
|
| 168 |
-
card_key = f"session_observation:{latest['id']}:{signal}"
|
| 169 |
-
if card_key in dismissed:
|
| 170 |
-
return []
|
| 171 |
-
return [{
|
| 172 |
-
"type": "session_observation",
|
| 173 |
-
"card_key": card_key,
|
| 174 |
-
"session_id": latest["id"],
|
| 175 |
-
"context": latest["context"],
|
| 176 |
-
"date": latest["date"],
|
| 177 |
-
"signal": signal,
|
| 178 |
-
"note": obs.get("observation", ""),
|
| 179 |
-
"resonance_prompt": obs.get("resonance_prompt", ""),
|
| 180 |
-
}]
|
|
|
|
| 1 |
+
"""Home feed card builders — v2: a single interleaved timeline of two card
|
| 2 |
+
sources, replacing the earlier v1 (5 static per-signal card types built fresh
|
| 3 |
+
from evidence on every read, no event history, no triggers).
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
- Session recap cards: one unconditional card per session, dated to that
|
| 6 |
+
session, built straight from insight_generator's existing output — zero
|
| 7 |
+
new LLM cost.
|
| 8 |
+
- Dimension event cards: one card per row in `dimension_events` (written by
|
| 9 |
+
pipeline/trigger_detector.py at finalize time) — frozen content, dated to
|
| 10 |
+
the session that triggered them, so they interleave genuinely by date
|
| 11 |
+
alongside recap cards rather than always sorting as "now".
|
| 12 |
+
|
| 13 |
+
Both card types share the existing `dismissed_cards` dismissal mechanism.
|
| 14 |
+
`build_how_it_may_land_cards` is kept (not currently called from /api/home)
|
| 15 |
+
pending its future home on the You page's "how it may land" section.
|
| 16 |
"""
|
| 17 |
|
| 18 |
from pipeline.evidence_gate import SIGNAL_EVIDENCE_CONFIG
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
+
def build_session_recap_cards(parsed: list, dismissed: set) -> list:
|
| 22 |
+
"""One card per session, unconditional. Uses the FULL insight_generator
|
| 23 |
+
output (conversation_summary, observations, coaching_suggestions,
|
| 24 |
+
notable_pattern) rather than just the single top observation the old v1
|
| 25 |
+
session_observation card showed."""
|
| 26 |
cards = []
|
| 27 |
+
for p in parsed:
|
| 28 |
+
card_key = f"session_recap:{p['id']}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
if card_key in dismissed:
|
| 30 |
continue
|
| 31 |
+
ins = p.get("ins") or {}
|
| 32 |
cards.append({
|
| 33 |
+
"type": "session_recap",
|
| 34 |
"card_key": card_key,
|
| 35 |
+
"session_id": p["id"],
|
| 36 |
+
"context": p["context"],
|
| 37 |
+
"date": p["date"],
|
| 38 |
+
"conversation_summary": ins.get("conversation_summary", ""),
|
| 39 |
+
"observations": ins.get("observations", []),
|
| 40 |
+
"coaching_suggestions": ins.get("coaching_suggestions", []),
|
| 41 |
+
"notable_pattern": ins.get("notable_pattern"),
|
| 42 |
})
|
| 43 |
return cards
|
| 44 |
|
| 45 |
|
| 46 |
+
def build_dimension_event_cards(events: list, dismissed: set) -> list:
|
| 47 |
+
"""`events` = rows fetched from the `dimension_events` table by main.py.
|
| 48 |
+
Content is frozen at fire-time (card_copy_json) — never regenerated here,
|
| 49 |
+
even if the underlying evidence has since shifted again (a new event
|
| 50 |
+
fires separately instead)."""
|
| 51 |
+
import json
|
| 52 |
+
|
| 53 |
cards = []
|
| 54 |
+
for e in events:
|
| 55 |
+
card_key = f"dimension_event:{e['id']}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
if card_key in dismissed:
|
| 57 |
continue
|
| 58 |
+
try:
|
| 59 |
+
copy = json.loads(e["card_copy_json"])
|
| 60 |
+
except (KeyError, TypeError, ValueError):
|
| 61 |
+
copy = {}
|
| 62 |
cards.append({
|
| 63 |
+
"type": "dimension_event",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
"card_key": card_key,
|
| 65 |
+
"dimension_key": e["dimension_key"],
|
| 66 |
+
"scope": e["scope"],
|
| 67 |
+
"trigger_type": e["trigger_type"],
|
| 68 |
+
"direction": e.get("direction"),
|
| 69 |
+
"session_id": e["session_id"],
|
| 70 |
+
"date": e["created_at"],
|
| 71 |
+
"label": copy.get("label", SIGNAL_EVIDENCE_CONFIG.get(e["dimension_key"], {}).get("label", e["dimension_key"])),
|
| 72 |
+
"note": copy.get("note", ""),
|
| 73 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
return cards
|
| 75 |
|
| 76 |
|
|
|
|
| 91 |
"note": entry.get("note", ""),
|
| 92 |
})
|
| 93 |
return cards
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import json
|
| 2 |
from anthropic import Anthropic
|
| 3 |
|
| 4 |
-
from pipeline.evidence_gate import SIGNAL_EVIDENCE_CONFIG
|
| 5 |
from pipeline.llm_utils import extract_text
|
| 6 |
|
| 7 |
|
|
@@ -98,22 +98,28 @@ class InsightGenerator:
|
|
| 98 |
# in which case it's listed as not-yet-steady so the prompt can explicitly
|
| 99 |
# instruct against inventing a pattern for it. Self-relative only (rule #4) —
|
| 100 |
# no population comparison of any kind.
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
"hedging": prepared["hedging_rate"],
|
| 107 |
-
"directness": prepared["directness_rate"],
|
| 108 |
-
"question_impact": prepared["question_pickup_rate"],
|
| 109 |
-
"drive_vs_follow": prepared["drive_score"],
|
| 110 |
-
"building_on_others": prepared["building_on_rate"],
|
| 111 |
-
}
|
| 112 |
steady = {}
|
| 113 |
not_yet_steady = []
|
| 114 |
for sig_key, sig_evidence in evidence.get("signals", {}).items():
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
delta = current - sig_evidence["mean"]
|
| 118 |
delta_pct = (delta / sig_evidence["mean"] * 100) if sig_evidence["mean"] else None
|
| 119 |
steady[sig_key] = {
|
|
@@ -181,6 +187,16 @@ CONVERSATION TRANSCRIPT:
|
|
| 181 |
lines = []
|
| 182 |
for sig_key, c in ev["steady"].items():
|
| 183 |
label = SIGNAL_EVIDENCE_CONFIG[sig_key]["label"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
if c["delta"] > 0:
|
| 185 |
arrow = "more than"
|
| 186 |
elif c["delta"] < 0:
|
|
|
|
| 1 |
import json
|
| 2 |
from anthropic import Anthropic
|
| 3 |
|
| 4 |
+
from pipeline.evidence_gate import SIGNAL_EVIDENCE_CONFIG, extract_value
|
| 5 |
from pipeline.llm_utils import extract_text
|
| 6 |
|
| 7 |
|
|
|
|
| 98 |
# in which case it's listed as not-yet-steady so the prompt can explicitly
|
| 99 |
# instruct against inventing a pattern for it. Self-relative only (rule #4) —
|
| 100 |
# no population comparison of any kind.
|
| 101 |
+
#
|
| 102 |
+
# Uses extract_value (the same extraction logic the evidence system itself
|
| 103 |
+
# uses) rather than a hand-maintained key mapping — a parallel mapping here
|
| 104 |
+
# would silently go stale every time a dimension is renamed or added, which
|
| 105 |
+
# is exactly what happened to the old 9-key version of this block.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
steady = {}
|
| 107 |
not_yet_steady = []
|
| 108 |
for sig_key, sig_evidence in evidence.get("signals", {}).items():
|
| 109 |
+
try:
|
| 110 |
+
current = extract_value(sig_key, signals)
|
| 111 |
+
except (KeyError, TypeError):
|
| 112 |
+
current = None
|
| 113 |
+
is_categorical = SIGNAL_EVIDENCE_CONFIG.get(sig_key, {}).get("kind") == "categorical"
|
| 114 |
+
|
| 115 |
+
if sig_evidence["is_steady"] and current is not None and is_categorical:
|
| 116 |
+
steady[sig_key] = {
|
| 117 |
+
"current_label": current,
|
| 118 |
+
"your_usual_label": sig_evidence["mode_label"],
|
| 119 |
+
"agreement_ratio": sig_evidence["agreement_ratio"],
|
| 120 |
+
"sample_count": sig_evidence["sample_count"],
|
| 121 |
+
}
|
| 122 |
+
elif sig_evidence["is_steady"] and current is not None:
|
| 123 |
delta = current - sig_evidence["mean"]
|
| 124 |
delta_pct = (delta / sig_evidence["mean"] * 100) if sig_evidence["mean"] else None
|
| 125 |
steady[sig_key] = {
|
|
|
|
| 187 |
lines = []
|
| 188 |
for sig_key, c in ev["steady"].items():
|
| 189 |
label = SIGNAL_EVIDENCE_CONFIG[sig_key]["label"]
|
| 190 |
+
if "current_label" in c:
|
| 191 |
+
# Categorical dim (pacing_arc, energy_arc) — no numeric delta,
|
| 192 |
+
# just whether this session's label matches the established mode.
|
| 193 |
+
same = c["current_label"] == c["your_usual_label"]
|
| 194 |
+
lines.append(
|
| 195 |
+
f" {label}: {c['current_label']} this session "
|
| 196 |
+
f"({'matches' if same else 'differs from'} your usual {c['your_usual_label']}, "
|
| 197 |
+
f"based on {c['sample_count']} past {ctx_label} sessions)"
|
| 198 |
+
)
|
| 199 |
+
continue
|
| 200 |
if c["delta"] > 0:
|
| 201 |
arrow = "more than"
|
| 202 |
elif c["delta"] < 0:
|
|
@@ -5,23 +5,33 @@ from pipeline.evidence_gate import SIGNAL_EVIDENCE_CONFIG
|
|
| 5 |
from pipeline.llm_utils import extract_text
|
| 6 |
|
| 7 |
# How to format each signal's raw mean into human-readable text for the prompt.
|
| 8 |
-
# (value * scale) formatted with `fmt`, followed by `unit`.
|
|
|
|
|
|
|
| 9 |
_SIGNAL_FORMAT = {
|
| 10 |
-
"talk_ratio":
|
| 11 |
-
"
|
| 12 |
-
"
|
| 13 |
-
"
|
| 14 |
-
"hedging":
|
| 15 |
-
"directness":
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
|
| 22 |
-
def _format_mean(signal_key: str,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
scale, fmt, unit = _SIGNAL_FORMAT[signal_key]
|
| 24 |
-
return fmt.format(
|
| 25 |
|
| 26 |
|
| 27 |
class PortraitSynthesizer:
|
|
@@ -35,7 +45,8 @@ class PortraitSynthesizer:
|
|
| 35 |
def __init__(self, api_key: str):
|
| 36 |
self.client = Anthropic(api_key=api_key)
|
| 37 |
|
| 38 |
-
def synthesize(self, evidence: dict, blind_spots: list, session_count: int
|
|
|
|
| 39 |
overall = evidence.get("overall", {})
|
| 40 |
by_context = evidence.get("by_context", {})
|
| 41 |
|
|
@@ -48,8 +59,10 @@ class PortraitSynthesizer:
|
|
| 48 |
# their own contexts, never against other people.
|
| 49 |
context_shift_candidates = {}
|
| 50 |
for signal_key in SIGNAL_EVIDENCE_CONFIG:
|
|
|
|
|
|
|
| 51 |
steady_contexts = {
|
| 52 |
-
ctx: data[signal_key][
|
| 53 |
for ctx, data in by_context.items()
|
| 54 |
if data.get(signal_key, {}).get("is_steady")
|
| 55 |
}
|
|
@@ -59,7 +72,8 @@ class PortraitSynthesizer:
|
|
| 59 |
if not steady_overall and not context_shift_candidates:
|
| 60 |
return {"signals": [], "context_shifts": [], "how_it_may_land": []}
|
| 61 |
|
| 62 |
-
prompt = self._build_prompt(steady_overall, context_shift_candidates, session_count
|
|
|
|
| 63 |
response = self.client.messages.create(
|
| 64 |
model="claude-sonnet-5",
|
| 65 |
max_tokens=1800,
|
|
@@ -69,27 +83,32 @@ class PortraitSynthesizer:
|
|
| 69 |
return self._parse(extract_text(response))
|
| 70 |
|
| 71 |
def _build_prompt(self, steady_overall: dict, context_shift_candidates: dict,
|
| 72 |
-
session_count: int) -> str:
|
| 73 |
signal_lines = []
|
| 74 |
for signal_key, ev in steady_overall.items():
|
| 75 |
label = SIGNAL_EVIDENCE_CONFIG[signal_key]["label"]
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
f"based on {ev['sample_count']} sessions"
|
| 78 |
signal_lines.append(desc)
|
| 79 |
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
context_lines = []
|
| 83 |
for signal_key, by_ctx in context_shift_candidates.items():
|
| 84 |
label = SIGNAL_EVIDENCE_CONFIG[signal_key]["label"]
|
| 85 |
parts = ", ".join(
|
| 86 |
-
f"{ctx.replace('_', ' ')}: {_format_mean(signal_key,
|
| 87 |
-
for ctx,
|
| 88 |
)
|
| 89 |
context_lines.append(f" {label} ({signal_key}) — {parts}")
|
| 90 |
|
| 91 |
how_it_may_land_task = (
|
| 92 |
-
"\nALSO: \"question follow-through\"
|
| 93 |
"additional sentence describing how this person's questions tend to LAND in the room — "
|
| 94 |
"effect-on-others phrasing, e.g. \"your questions tend to get picked up and built on by "
|
| 95 |
"the room\" — still self-relative (this person's own tendency), never a claim about what "
|
|
@@ -98,7 +117,7 @@ class PortraitSynthesizer:
|
|
| 98 |
)
|
| 99 |
how_it_may_land_schema = (
|
| 100 |
',\n "how_it_may_land": [\n'
|
| 101 |
-
' {"signal_key": "
|
| 102 |
if wants_how_it_may_land else ""
|
| 103 |
)
|
| 104 |
|
|
|
|
| 5 |
from pipeline.llm_utils import extract_text
|
| 6 |
|
| 7 |
# How to format each signal's raw mean into human-readable text for the prompt.
|
| 8 |
+
# (value * scale) formatted with `fmt`, followed by `unit`. Categorical signals
|
| 9 |
+
# (pacing_arc, energy_arc) aren't listed here — they have no numeric mean to
|
| 10 |
+
# scale, see the branch in _format_mean below.
|
| 11 |
_SIGNAL_FORMAT = {
|
| 12 |
+
"talk_ratio": (100, "{:.0f}", "% of speaking time"),
|
| 13 |
+
"curiosity": (1, "{:.2f}", " question-turns per 100 words"),
|
| 14 |
+
"turn_taking_assertiveness": (1, "{:.1f}", " interruptions per 10 speaker changes"),
|
| 15 |
+
"conversational_drive": (100, "{:.0f}", "% drive score (higher = initiates more, lower = follows more)"),
|
| 16 |
+
"hedging": (1, "{:.1f}", " hedging phrases per 100 words"),
|
| 17 |
+
"directness": (1, "{:.1f}", " direct/assertive phrases per 100 words"),
|
| 18 |
+
"building_on_others": (100, "{:.0f}", "% of your turns build on someone else's point"),
|
| 19 |
+
"pace": (1, "{:.0f}", " words per minute"),
|
| 20 |
+
"vocal_expressiveness": (1, "{:.1f}", " Hz of pitch variation"),
|
| 21 |
+
"turn_length": (1, "{:.1f}", "s per turn on average"),
|
| 22 |
+
"vocabulary_richness": (100, "{:.0f}", "% unique words in a typical stretch of speech"),
|
| 23 |
+
"fillers": (1, "{:.2f}", " filler words per 100 words"),
|
| 24 |
+
"response_latency": (1, "{:.1f}", "s before responding"),
|
| 25 |
}
|
| 26 |
|
| 27 |
|
| 28 |
+
def _format_mean(signal_key: str, value) -> str:
|
| 29 |
+
"""Formats a continuous signal's numeric mean, or a categorical signal's
|
| 30 |
+
string mode label, into human-readable text for the prompt."""
|
| 31 |
+
if SIGNAL_EVIDENCE_CONFIG.get(signal_key, {}).get("kind") == "categorical":
|
| 32 |
+
return f"consistently {value}"
|
| 33 |
scale, fmt, unit = _SIGNAL_FORMAT[signal_key]
|
| 34 |
+
return fmt.format(value * scale) + unit
|
| 35 |
|
| 36 |
|
| 37 |
class PortraitSynthesizer:
|
|
|
|
| 45 |
def __init__(self, api_key: str):
|
| 46 |
self.client = Anthropic(api_key=api_key)
|
| 47 |
|
| 48 |
+
def synthesize(self, evidence: dict, blind_spots: list, session_count: int,
|
| 49 |
+
sub_evidence: dict = None) -> dict:
|
| 50 |
overall = evidence.get("overall", {})
|
| 51 |
by_context = evidence.get("by_context", {})
|
| 52 |
|
|
|
|
| 59 |
# their own contexts, never against other people.
|
| 60 |
context_shift_candidates = {}
|
| 61 |
for signal_key in SIGNAL_EVIDENCE_CONFIG:
|
| 62 |
+
is_categorical = SIGNAL_EVIDENCE_CONFIG[signal_key]["kind"] == "categorical"
|
| 63 |
+
value_field = "mode_label" if is_categorical else "mean"
|
| 64 |
steady_contexts = {
|
| 65 |
+
ctx: data[signal_key][value_field]
|
| 66 |
for ctx, data in by_context.items()
|
| 67 |
if data.get(signal_key, {}).get("is_steady")
|
| 68 |
}
|
|
|
|
| 72 |
if not steady_overall and not context_shift_candidates:
|
| 73 |
return {"signals": [], "context_shifts": [], "how_it_may_land": []}
|
| 74 |
|
| 75 |
+
prompt = self._build_prompt(steady_overall, context_shift_candidates, session_count,
|
| 76 |
+
sub_evidence or {})
|
| 77 |
response = self.client.messages.create(
|
| 78 |
model="claude-sonnet-5",
|
| 79 |
max_tokens=1800,
|
|
|
|
| 83 |
return self._parse(extract_text(response))
|
| 84 |
|
| 85 |
def _build_prompt(self, steady_overall: dict, context_shift_candidates: dict,
|
| 86 |
+
session_count: int, sub_evidence: dict) -> str:
|
| 87 |
signal_lines = []
|
| 88 |
for signal_key, ev in steady_overall.items():
|
| 89 |
label = SIGNAL_EVIDENCE_CONFIG[signal_key]["label"]
|
| 90 |
+
is_categorical = SIGNAL_EVIDENCE_CONFIG[signal_key]["kind"] == "categorical"
|
| 91 |
+
value = ev["mode_label"] if is_categorical else ev["mean"]
|
| 92 |
+
desc = f" {label} ({signal_key}): established at {_format_mean(signal_key, value)}, " \
|
| 93 |
f"based on {ev['sample_count']} sessions"
|
| 94 |
signal_lines.append(desc)
|
| 95 |
|
| 96 |
+
# question_pickup is a sub-signal (folded into "curiosity" on Home, see
|
| 97 |
+
# evidence_gate.SUB_SIGNAL_EVIDENCE_CONFIG) — not part of steady_overall,
|
| 98 |
+
# so its own evidence dict is passed in separately.
|
| 99 |
+
wants_how_it_may_land = sub_evidence.get("question_pickup", {}).get("is_steady", False)
|
| 100 |
|
| 101 |
context_lines = []
|
| 102 |
for signal_key, by_ctx in context_shift_candidates.items():
|
| 103 |
label = SIGNAL_EVIDENCE_CONFIG[signal_key]["label"]
|
| 104 |
parts = ", ".join(
|
| 105 |
+
f"{ctx.replace('_', ' ')}: {_format_mean(signal_key, value)}"
|
| 106 |
+
for ctx, value in by_ctx.items()
|
| 107 |
)
|
| 108 |
context_lines.append(f" {label} ({signal_key}) — {parts}")
|
| 109 |
|
| 110 |
how_it_may_land_task = (
|
| 111 |
+
"\nALSO: \"question follow-through\" is established. Write ONE "
|
| 112 |
"additional sentence describing how this person's questions tend to LAND in the room — "
|
| 113 |
"effect-on-others phrasing, e.g. \"your questions tend to get picked up and built on by "
|
| 114 |
"the room\" — still self-relative (this person's own tendency), never a claim about what "
|
|
|
|
| 117 |
)
|
| 118 |
how_it_may_land_schema = (
|
| 119 |
',\n "how_it_may_land": [\n'
|
| 120 |
+
' {"signal_key": "curiosity", "note": "one sentence"}\n ]'
|
| 121 |
if wants_how_it_may_land else ""
|
| 122 |
)
|
| 123 |
|
|
@@ -106,13 +106,18 @@ class SignalExtractor:
|
|
| 106 |
|
| 107 |
turn_dynamics = self._compute_turn_dynamics(user_segs, other_segs)
|
| 108 |
questions = self._compute_questions(user_segs, other_segs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
signals = {
|
| 111 |
"session_duration_s": self._get_session_duration(),
|
| 112 |
"talk_ratio": self._compute_talk_ratio(user_segs, all_other_segs),
|
| 113 |
"speech_rate": self._compute_speech_rate(user_segs),
|
| 114 |
"pauses": self._compute_pauses(user_segs, other_segs),
|
| 115 |
-
"interruptions":
|
| 116 |
"filler_words": self._compute_filler_words(user_segs),
|
| 117 |
"turn_dynamics": turn_dynamics,
|
| 118 |
"pitch_features": self._compute_pitch_features(user_segs),
|
|
@@ -120,6 +125,7 @@ class SignalExtractor:
|
|
| 120 |
"vocal_energy": self._compute_vocal_energy(user_segs),
|
| 121 |
"speech_acceleration": self._compute_speech_acceleration(user_segs),
|
| 122 |
"questions": questions,
|
|
|
|
| 123 |
"monologue": self._compute_monologue(user_segs),
|
| 124 |
"vocabulary_richness": self._compute_vocabulary_richness(user_segs),
|
| 125 |
"silence_ratio": self._compute_silence_ratio(user_segs, all_other_segs),
|
|
@@ -129,7 +135,7 @@ class SignalExtractor:
|
|
| 129 |
"directness": self._compute_directness(user_segs),
|
| 130 |
"question_impact": self._compute_question_impact(user_segs, all_other_segs),
|
| 131 |
"drive_vs_follow": self._compute_drive_vs_follow(
|
| 132 |
-
user_segs, other_segs, all_other_segs, turn_dynamics, questions
|
| 133 |
),
|
| 134 |
"building_on_others": self._compute_building_on_others(user_segs, all_other_segs),
|
| 135 |
}
|
|
@@ -229,10 +235,14 @@ class SignalExtractor:
|
|
| 229 |
return sorted(turns, key=lambda x: x["start"])
|
| 230 |
|
| 231 |
def _compute_interruptions(self, user_segs, other_segs) -> dict:
|
|
|
|
|
|
|
|
|
|
| 232 |
all_turns = self._build_all_turns(user_segs, other_segs)
|
| 233 |
|
| 234 |
user_interrupts_other = 0
|
| 235 |
other_interrupts_user = 0
|
|
|
|
| 236 |
|
| 237 |
for i in range(1, len(all_turns)):
|
| 238 |
prev = all_turns[i - 1]
|
|
@@ -240,6 +250,7 @@ class SignalExtractor:
|
|
| 240 |
|
| 241 |
if prev["speaker"] == curr["speaker"]:
|
| 242 |
continue
|
|
|
|
| 243 |
|
| 244 |
gap = curr["start"] - prev["end"]
|
| 245 |
prev_duration = prev["end"] - prev["start"]
|
|
@@ -252,7 +263,10 @@ class SignalExtractor:
|
|
| 252 |
|
| 253 |
return {
|
| 254 |
"user_interrupted_other": user_interrupts_other,
|
| 255 |
-
"user_was_interrupted": other_interrupts_user
|
|
|
|
|
|
|
|
|
|
| 256 |
}
|
| 257 |
|
| 258 |
def _compute_hedging(self, user_segs) -> dict:
|
|
@@ -291,6 +305,42 @@ class SignalExtractor:
|
|
| 291 |
"breakdown": marker_counts
|
| 292 |
}
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
def _compute_question_impact(self, user_segs, all_other_segs) -> dict:
|
| 295 |
"""Did the room pick up the user's questions? Uses the room-wide turn
|
| 296 |
sequence (any participant, not just the primary dyadic partner)."""
|
|
@@ -303,11 +353,7 @@ class SignalExtractor:
|
|
| 303 |
for i, turn in enumerate(all_turns):
|
| 304 |
if turn["speaker"] != self.user_speaker:
|
| 305 |
continue
|
| 306 |
-
|
| 307 |
-
is_question = "?" in text or any(
|
| 308 |
-
re.search(r'\b' + w + r'\b', text) for w in self.QUESTION_WORDS
|
| 309 |
-
)
|
| 310 |
-
if not is_question:
|
| 311 |
continue
|
| 312 |
total_questions += 1
|
| 313 |
|
|
@@ -334,11 +380,15 @@ class SignalExtractor:
|
|
| 334 |
"avg_pickup_latency_s": round(float(np.mean(pickup_latencies)), 3) if pickup_latencies else None,
|
| 335 |
}
|
| 336 |
|
| 337 |
-
def _compute_drive_vs_follow(self, user_segs, other_segs, all_other_segs, turn_dynamics,
|
| 338 |
-
|
| 339 |
-
"
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
all_turns = self._build_all_turns(user_segs, all_other_segs)
|
| 343 |
|
| 344 |
user_turns_total = 0
|
|
@@ -366,8 +416,14 @@ class SignalExtractor:
|
|
| 366 |
total_q = user_q + other_q
|
| 367 |
question_asymmetry = round(user_q / total_q, 3) if total_q > 0 else 0.5
|
| 368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
drive_score = round(
|
| 370 |
-
0.
|
|
|
|
| 371 |
)
|
| 372 |
|
| 373 |
return {
|
|
@@ -375,6 +431,7 @@ class SignalExtractor:
|
|
| 375 |
"initiation_fraction": initiation_fraction,
|
| 376 |
"turn_length_asymmetry": turn_length_asymmetry,
|
| 377 |
"question_asymmetry": question_asymmetry,
|
|
|
|
| 378 |
}
|
| 379 |
|
| 380 |
def _compute_building_on_others(self, user_segs, all_other_segs) -> dict:
|
|
@@ -384,6 +441,8 @@ class SignalExtractor:
|
|
| 384 |
all_turns = self._build_all_turns(user_segs, all_other_segs)
|
| 385 |
|
| 386 |
total_user_turns = 0
|
|
|
|
|
|
|
| 387 |
marker_matches = 0
|
| 388 |
overlap_matches = 0
|
| 389 |
building_on_count = 0
|
|
@@ -393,14 +452,17 @@ class SignalExtractor:
|
|
| 393 |
continue
|
| 394 |
total_user_turns += 1
|
| 395 |
text = turn["text"].lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
has_marker = any(text.startswith(m) for m in self.BUILDING_ON_MARKERS)
|
| 398 |
if has_marker:
|
| 399 |
marker_matches += 1
|
| 400 |
|
| 401 |
has_overlap = False
|
| 402 |
-
|
| 403 |
-
if prev and prev["speaker"] != self.user_speaker:
|
| 404 |
prev_words = {
|
| 405 |
w for w in re.findall(r"[a-z']+", prev["text"].lower())
|
| 406 |
if len(w) > 3 and w not in self.STOPWORDS
|
|
@@ -417,10 +479,15 @@ class SignalExtractor:
|
|
| 417 |
building_on_count += 1
|
| 418 |
|
| 419 |
return {
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
"marker_matches": marker_matches,
|
| 422 |
"lexical_overlap_matches": overlap_matches,
|
| 423 |
"total_user_turns": total_user_turns,
|
|
|
|
| 424 |
"interpretation_note": "approximate proxy — marker phrases + lexical overlap, not semantic reasoning",
|
| 425 |
}
|
| 426 |
|
|
@@ -597,29 +664,38 @@ class SignalExtractor:
|
|
| 597 |
"""Detect long uninterrupted speaking stretches."""
|
| 598 |
long_turns = [s for s in user_segs if (s["end"] - s["start"]) > 30]
|
| 599 |
all_lengths = [s["end"] - s["start"] for s in user_segs]
|
|
|
|
| 600 |
|
| 601 |
return {
|
| 602 |
"long_turn_count": len(long_turns), # turns > 30 seconds
|
| 603 |
"longest_turn_s": round(max(all_lengths), 1) if all_lengths else 0,
|
| 604 |
-
"avg_turn_length_s": round(float(np.mean(all_lengths)), 1) if all_lengths else 0
|
|
|
|
| 605 |
}
|
| 606 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
def _compute_vocabulary_richness(self, user_segs) -> dict:
|
| 608 |
-
"""Type-token ratio: unique words / total words
|
|
|
|
| 609 |
all_text = " ".join(s.get("text", "").lower() for s in user_segs)
|
| 610 |
words = re.findall(r'\b[a-zA-Z\u0900-\u097F]+\b', all_text)
|
| 611 |
|
| 612 |
-
if len(words) <
|
| 613 |
return {"type_token_ratio": None, "unique_words": 0, "total_words": len(words)}
|
| 614 |
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
ttr = round(unique /
|
| 618 |
|
| 619 |
return {
|
| 620 |
"type_token_ratio": ttr,
|
| 621 |
"unique_words": unique,
|
| 622 |
-
"total_words":
|
| 623 |
}
|
| 624 |
|
| 625 |
def _compute_silence_ratio(self, user_segs, other_segs) -> dict:
|
|
|
|
| 106 |
|
| 107 |
turn_dynamics = self._compute_turn_dynamics(user_segs, other_segs)
|
| 108 |
questions = self._compute_questions(user_segs, other_segs)
|
| 109 |
+
# Room-wide (all_other_segs), not dyadic — otherwise this collapses a
|
| 110 |
+
# multi-party meeting to one arbitrary "other" (CLAUDE.md forbids this).
|
| 111 |
+
# Computed before drive_vs_follow so its interruption_asymmetry input
|
| 112 |
+
# can reuse this result instead of computing interruptions twice.
|
| 113 |
+
interruptions = self._compute_interruptions(user_segs, all_other_segs)
|
| 114 |
|
| 115 |
signals = {
|
| 116 |
"session_duration_s": self._get_session_duration(),
|
| 117 |
"talk_ratio": self._compute_talk_ratio(user_segs, all_other_segs),
|
| 118 |
"speech_rate": self._compute_speech_rate(user_segs),
|
| 119 |
"pauses": self._compute_pauses(user_segs, other_segs),
|
| 120 |
+
"interruptions": interruptions,
|
| 121 |
"filler_words": self._compute_filler_words(user_segs),
|
| 122 |
"turn_dynamics": turn_dynamics,
|
| 123 |
"pitch_features": self._compute_pitch_features(user_segs),
|
|
|
|
| 125 |
"vocal_energy": self._compute_vocal_energy(user_segs),
|
| 126 |
"speech_acceleration": self._compute_speech_acceleration(user_segs),
|
| 127 |
"questions": questions,
|
| 128 |
+
"curiosity": self._compute_curiosity(user_segs, all_other_segs),
|
| 129 |
"monologue": self._compute_monologue(user_segs),
|
| 130 |
"vocabulary_richness": self._compute_vocabulary_richness(user_segs),
|
| 131 |
"silence_ratio": self._compute_silence_ratio(user_segs, all_other_segs),
|
|
|
|
| 135 |
"directness": self._compute_directness(user_segs),
|
| 136 |
"question_impact": self._compute_question_impact(user_segs, all_other_segs),
|
| 137 |
"drive_vs_follow": self._compute_drive_vs_follow(
|
| 138 |
+
user_segs, other_segs, all_other_segs, turn_dynamics, questions, interruptions
|
| 139 |
),
|
| 140 |
"building_on_others": self._compute_building_on_others(user_segs, all_other_segs),
|
| 141 |
}
|
|
|
|
| 235 |
return sorted(turns, key=lambda x: x["start"])
|
| 236 |
|
| 237 |
def _compute_interruptions(self, user_segs, other_segs) -> dict:
|
| 238 |
+
"""`other_segs` should be the room-wide `all_other_segs` (not just the
|
| 239 |
+
primary dyadic partner) — otherwise this silently collapses a
|
| 240 |
+
multi-party meeting to one arbitrary "other", which CLAUDE.md forbids."""
|
| 241 |
all_turns = self._build_all_turns(user_segs, other_segs)
|
| 242 |
|
| 243 |
user_interrupts_other = 0
|
| 244 |
other_interrupts_user = 0
|
| 245 |
+
transitions = 0
|
| 246 |
|
| 247 |
for i in range(1, len(all_turns)):
|
| 248 |
prev = all_turns[i - 1]
|
|
|
|
| 250 |
|
| 251 |
if prev["speaker"] == curr["speaker"]:
|
| 252 |
continue
|
| 253 |
+
transitions += 1
|
| 254 |
|
| 255 |
gap = curr["start"] - prev["end"]
|
| 256 |
prev_duration = prev["end"] - prev["start"]
|
|
|
|
| 263 |
|
| 264 |
return {
|
| 265 |
"user_interrupted_other": user_interrupts_other,
|
| 266 |
+
"user_was_interrupted": other_interrupts_user,
|
| 267 |
+
"total_transitions": transitions,
|
| 268 |
+
"user_interrupt_rate_per_10_transitions": round(user_interrupts_other / transitions * 10, 2) if transitions else None,
|
| 269 |
+
"user_was_interrupted_rate_per_10_transitions": round(other_interrupts_user / transitions * 10, 2) if transitions else None,
|
| 270 |
}
|
| 271 |
|
| 272 |
def _compute_hedging(self, user_segs) -> dict:
|
|
|
|
| 305 |
"breakdown": marker_counts
|
| 306 |
}
|
| 307 |
|
| 308 |
+
@staticmethod
|
| 309 |
+
def _is_question_turn(text: str) -> bool:
|
| 310 |
+
"""A turn counts as a question if it contains '?' or a question-word
|
| 311 |
+
(English or Hindi). Shared by _compute_question_impact and
|
| 312 |
+
_compute_curiosity so both count "a question" the same way."""
|
| 313 |
+
text = text.lower()
|
| 314 |
+
return "?" in text or any(
|
| 315 |
+
re.search(r'\b' + w + r'\b', text) for w in SignalExtractor.QUESTION_WORDS
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
def _compute_curiosity(self, user_segs, all_other_segs) -> dict:
|
| 319 |
+
"""Rate of the user's turns that are questions, per 100 words spoken —
|
| 320 |
+
a rate rather than _compute_questions' raw "?"-count, so it's comparable
|
| 321 |
+
across sessions of different length. Uses the same per-turn question
|
| 322 |
+
test as _compute_question_impact for consistency between the two."""
|
| 323 |
+
all_turns = self._build_all_turns(user_segs, all_other_segs)
|
| 324 |
+
|
| 325 |
+
user_turns = 0
|
| 326 |
+
question_turns = 0
|
| 327 |
+
for turn in all_turns:
|
| 328 |
+
if turn["speaker"] != self.user_speaker:
|
| 329 |
+
continue
|
| 330 |
+
user_turns += 1
|
| 331 |
+
if self._is_question_turn(turn["text"]):
|
| 332 |
+
question_turns += 1
|
| 333 |
+
|
| 334 |
+
total_words = sum(len(s.get("words", [])) for s in user_segs)
|
| 335 |
+
rate = round(question_turns / total_words * 100, 2) if total_words > 0 else None
|
| 336 |
+
|
| 337 |
+
return {
|
| 338 |
+
"question_turn_count": question_turns,
|
| 339 |
+
"user_turns": user_turns,
|
| 340 |
+
"total_words": total_words,
|
| 341 |
+
"question_turn_rate_per_100_words": rate,
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
def _compute_question_impact(self, user_segs, all_other_segs) -> dict:
|
| 345 |
"""Did the room pick up the user's questions? Uses the room-wide turn
|
| 346 |
sequence (any participant, not just the primary dyadic partner)."""
|
|
|
|
| 353 |
for i, turn in enumerate(all_turns):
|
| 354 |
if turn["speaker"] != self.user_speaker:
|
| 355 |
continue
|
| 356 |
+
if not self._is_question_turn(turn["text"]):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
continue
|
| 358 |
total_questions += 1
|
| 359 |
|
|
|
|
| 380 |
"avg_pickup_latency_s": round(float(np.mean(pickup_latencies)), 3) if pickup_latencies else None,
|
| 381 |
}
|
| 382 |
|
| 383 |
+
def _compute_drive_vs_follow(self, user_segs, other_segs, all_other_segs, turn_dynamics,
|
| 384 |
+
questions, interruptions) -> dict:
|
| 385 |
+
"""Composite of four already-computed signals — a single proxy (e.g. just
|
| 386 |
+
"turns not preceded by a question") is too thin on its own; three of these
|
| 387 |
+
inputs match dimension_scorer.py's _analyze_driver, and interruption_asymmetry
|
| 388 |
+
is added as a 4th, more direct signal of assertiveness (pure timing, not an
|
| 389 |
+
inferred proxy). Weighted equally (0.25 each) — the original 0.4/0.3/0.3
|
| 390 |
+
weights were never empirically validated, so there's no principled reason to
|
| 391 |
+
keep favoring one input now that a 4th is added."""
|
| 392 |
all_turns = self._build_all_turns(user_segs, all_other_segs)
|
| 393 |
|
| 394 |
user_turns_total = 0
|
|
|
|
| 416 |
total_q = user_q + other_q
|
| 417 |
question_asymmetry = round(user_q / total_q, 3) if total_q > 0 else 0.5
|
| 418 |
|
| 419 |
+
ui = interruptions.get("user_interrupted_other", 0)
|
| 420 |
+
uwi = interruptions.get("user_was_interrupted", 0)
|
| 421 |
+
total_i = ui + uwi
|
| 422 |
+
interruption_asymmetry = round(ui / total_i, 3) if total_i > 0 else 0.5
|
| 423 |
+
|
| 424 |
drive_score = round(
|
| 425 |
+
0.25 * initiation_fraction + 0.25 * turn_length_asymmetry +
|
| 426 |
+
0.25 * question_asymmetry + 0.25 * interruption_asymmetry, 3
|
| 427 |
)
|
| 428 |
|
| 429 |
return {
|
|
|
|
| 431 |
"initiation_fraction": initiation_fraction,
|
| 432 |
"turn_length_asymmetry": turn_length_asymmetry,
|
| 433 |
"question_asymmetry": question_asymmetry,
|
| 434 |
+
"interruption_asymmetry": interruption_asymmetry,
|
| 435 |
}
|
| 436 |
|
| 437 |
def _compute_building_on_others(self, user_segs, all_other_segs) -> dict:
|
|
|
|
| 441 |
all_turns = self._build_all_turns(user_segs, all_other_segs)
|
| 442 |
|
| 443 |
total_user_turns = 0
|
| 444 |
+
eligible_turns = 0 # user turns immediately following an other-speaker turn —
|
| 445 |
+
# the only turns where the lexical-overlap check can fire
|
| 446 |
marker_matches = 0
|
| 447 |
overlap_matches = 0
|
| 448 |
building_on_count = 0
|
|
|
|
| 452 |
continue
|
| 453 |
total_user_turns += 1
|
| 454 |
text = turn["text"].lower()
|
| 455 |
+
prev = all_turns[i - 1] if i > 0 else None
|
| 456 |
+
is_eligible = bool(prev and prev["speaker"] != self.user_speaker)
|
| 457 |
+
if is_eligible:
|
| 458 |
+
eligible_turns += 1
|
| 459 |
|
| 460 |
has_marker = any(text.startswith(m) for m in self.BUILDING_ON_MARKERS)
|
| 461 |
if has_marker:
|
| 462 |
marker_matches += 1
|
| 463 |
|
| 464 |
has_overlap = False
|
| 465 |
+
if is_eligible:
|
|
|
|
| 466 |
prev_words = {
|
| 467 |
w for w in re.findall(r"[a-z']+", prev["text"].lower())
|
| 468 |
if len(w) > 3 and w not in self.STOPWORDS
|
|
|
|
| 479 |
building_on_count += 1
|
| 480 |
|
| 481 |
return {
|
| 482 |
+
# Denominator is eligible_turns (turns that could structurally show
|
| 483 |
+
# overlap), not total_user_turns — turns following your own prior
|
| 484 |
+
# turn can only ever match via has_marker, so counting them in the
|
| 485 |
+
# denominator quietly diluted the rate.
|
| 486 |
+
"building_on_rate": round(building_on_count / eligible_turns, 3) if eligible_turns > 0 else 0,
|
| 487 |
"marker_matches": marker_matches,
|
| 488 |
"lexical_overlap_matches": overlap_matches,
|
| 489 |
"total_user_turns": total_user_turns,
|
| 490 |
+
"eligible_turns": eligible_turns,
|
| 491 |
"interpretation_note": "approximate proxy — marker phrases + lexical overlap, not semantic reasoning",
|
| 492 |
}
|
| 493 |
|
|
|
|
| 664 |
"""Detect long uninterrupted speaking stretches."""
|
| 665 |
long_turns = [s for s in user_segs if (s["end"] - s["start"]) > 30]
|
| 666 |
all_lengths = [s["end"] - s["start"] for s in user_segs]
|
| 667 |
+
total_turns = len(user_segs)
|
| 668 |
|
| 669 |
return {
|
| 670 |
"long_turn_count": len(long_turns), # turns > 30 seconds
|
| 671 |
"longest_turn_s": round(max(all_lengths), 1) if all_lengths else 0,
|
| 672 |
+
"avg_turn_length_s": round(float(np.mean(all_lengths)), 1) if all_lengths else 0,
|
| 673 |
+
"long_turn_rate": round(len(long_turns) / total_turns, 3) if total_turns > 0 else None,
|
| 674 |
}
|
| 675 |
|
| 676 |
+
# First N words only \u2014 type-token ratio mechanically decreases as text gets
|
| 677 |
+
# longer (common words get reused more), which would confound a whole-session
|
| 678 |
+
# TTR with session length rather than actual vocabulary behavior. A fixed
|
| 679 |
+
# window makes every session's TTR comparable regardless of how long it ran.
|
| 680 |
+
VOCAB_WINDOW_WORDS = 150
|
| 681 |
+
|
| 682 |
def _compute_vocabulary_richness(self, user_segs) -> dict:
|
| 683 |
+
"""Type-token ratio over a fixed-size window: unique words / total words
|
| 684 |
+
in the first VOCAB_WINDOW_WORDS words spoken. Higher = richer vocabulary."""
|
| 685 |
all_text = " ".join(s.get("text", "").lower() for s in user_segs)
|
| 686 |
words = re.findall(r'\b[a-zA-Z\u0900-\u097F]+\b', all_text)
|
| 687 |
|
| 688 |
+
if len(words) < self.VOCAB_WINDOW_WORDS:
|
| 689 |
return {"type_token_ratio": None, "unique_words": 0, "total_words": len(words)}
|
| 690 |
|
| 691 |
+
window = words[:self.VOCAB_WINDOW_WORDS]
|
| 692 |
+
unique = len(set(window))
|
| 693 |
+
ttr = round(unique / len(window), 3)
|
| 694 |
|
| 695 |
return {
|
| 696 |
"type_token_ratio": ttr,
|
| 697 |
"unique_words": unique,
|
| 698 |
+
"total_words": len(words), # full-session count, kept for display \u2014 TTR itself is windowed
|
| 699 |
}
|
| 700 |
|
| 701 |
def _compute_silence_ratio(self, user_segs, other_segs) -> dict:
|
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trigger-detection engine for the Home feed's dimension-maturation cards.
|
| 2 |
+
|
| 3 |
+
Diffs a user's per-dimension evidence — computed fresh after a session just
|
| 4 |
+
finalized — against the last known persisted state, and writes any newly
|
| 5 |
+
fired event into `dimension_events` (the frozen, append-only log the Home
|
| 6 |
+
feed reads). Also upserts `signal_evidence_state` (the "last known state"
|
| 7 |
+
row) every run, whether or not anything fires.
|
| 8 |
+
|
| 9 |
+
Five trigger types, per dimension+scope:
|
| 10 |
+
- first_time_steady: not steady -> steady, for the first time ever (overall scope)
|
| 11 |
+
- context_shift: same as above, but for a specific conversation-type scope
|
| 12 |
+
- recurring: was steady, lost steadiness, regained it (direction
|
| 13 |
+
distinguishes "back to usual" from a fresh drift found
|
| 14 |
+
via the regain)
|
| 15 |
+
- drift: already steady, established mean/mode has moved beyond
|
| 16 |
+
the dimension's own noise band
|
| 17 |
+
- anomaly: a single session's raw value contradicts an already-
|
| 18 |
+
established baseline — independent of the above, can
|
| 19 |
+
co-fire alongside any of them
|
| 20 |
+
|
| 21 |
+
Deliberately does NOT run from `reanalyze_session` (main.py) — retroactively
|
| 22 |
+
correcting which speaker is the user after evidence has already accumulated
|
| 23 |
+
on the wrong speaker's data is a reconciliation problem (would need to
|
| 24 |
+
un-fire/re-fire historical events) explicitly out of scope for this pass.
|
| 25 |
+
"""
|
| 26 |
+
import json
|
| 27 |
+
|
| 28 |
+
from db.database import supabase_admin
|
| 29 |
+
from pipeline.evidence_gate import (
|
| 30 |
+
SIGNAL_EVIDENCE_CONFIG, SUB_SIGNAL_EVIDENCE_CONFIG,
|
| 31 |
+
compute_evidence, extract_value,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
COOLDOWN_SESSIONS = 2 # minimum sessions between any two fired events for the same dimension+scope
|
| 35 |
+
ANOMALY_BAND_MULTIPLIER = 2.5 # anomaly band = this many x the dimension's own cv_threshold
|
| 36 |
+
STRONG_MODE_AGREEMENT = 0.80 # categorical anomaly requires an established mode at least this strong
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def run_trigger_detection(user_id: str, session_id: str, context: str) -> list:
|
| 40 |
+
"""Entry point — call right after _save_session() succeeds in main.py's
|
| 41 |
+
finalize flow. Re-fetches all parsed sessions (now including the
|
| 42 |
+
just-saved one) rather than taking `signals` as a param, so it reasons
|
| 43 |
+
over the exact same shape _compute_profile_evidence does. Returns the
|
| 44 |
+
list of newly fired events (for logging) — never raises; callers should
|
| 45 |
+
still wrap this in try/except so a bug here can never break finalize."""
|
| 46 |
+
from main import _fetch_and_parse_sessions # local import — avoids a circular import at module load
|
| 47 |
+
|
| 48 |
+
parsed = _fetch_and_parse_sessions(user_id)
|
| 49 |
+
if not parsed:
|
| 50 |
+
return []
|
| 51 |
+
|
| 52 |
+
fired = []
|
| 53 |
+
|
| 54 |
+
# Pooled ("overall") + this session's own context, for the 15 main dimensions.
|
| 55 |
+
for dimension_key, cfg in SIGNAL_EVIDENCE_CONFIG.items():
|
| 56 |
+
for scope in ("overall", context):
|
| 57 |
+
fired += _check_dimension(user_id, session_id, dimension_key, scope, parsed,
|
| 58 |
+
cfg, SIGNAL_EVIDENCE_CONFIG)
|
| 59 |
+
|
| 60 |
+
# Sub-signals: overall scope only, first_time_steady only (see their
|
| 61 |
+
# "allowed_triggers" in evidence_gate.SUB_SIGNAL_EVIDENCE_CONFIG).
|
| 62 |
+
for sub_key, cfg in SUB_SIGNAL_EVIDENCE_CONFIG.items():
|
| 63 |
+
fired += _check_dimension(user_id, session_id, sub_key, "overall", parsed,
|
| 64 |
+
cfg, SUB_SIGNAL_EVIDENCE_CONFIG,
|
| 65 |
+
allowed_triggers=cfg.get("allowed_triggers"))
|
| 66 |
+
|
| 67 |
+
return fired
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _scoped_values(parsed: list, signal_key: str, scope: str, config: dict) -> list:
|
| 71 |
+
"""Oldest->newest values for this signal, filtered to `scope` ('overall'
|
| 72 |
+
or a specific context string). Config-agnostic — works for both the main
|
| 73 |
+
15 dimensions and the 3 sub-signals via extract_value's dual-dict lookup."""
|
| 74 |
+
values = []
|
| 75 |
+
for p in parsed:
|
| 76 |
+
if scope != "overall" and p["context"] != scope:
|
| 77 |
+
continue
|
| 78 |
+
try:
|
| 79 |
+
values.append(extract_value(signal_key, p["sig"]))
|
| 80 |
+
except (KeyError, TypeError):
|
| 81 |
+
pass
|
| 82 |
+
return values
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _fetch_state(user_id: str, dimension_key: str, scope: str) -> dict:
|
| 86 |
+
res = supabase_admin.table("signal_evidence_state").select("*").eq(
|
| 87 |
+
"user_id", user_id
|
| 88 |
+
).eq("dimension_key", dimension_key).eq("scope", scope).execute()
|
| 89 |
+
return res.data[0] if res.data else {}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _upsert_state(user_id, dimension_key, scope, kind, is_steady, has_ever_been_steady,
|
| 93 |
+
last_steady_mean, last_steady_mode_label, last_steady_agreement_ratio,
|
| 94 |
+
sample_count, last_fired_trigger_type, last_fired_session_id,
|
| 95 |
+
last_fired_sample_count):
|
| 96 |
+
supabase_admin.table("signal_evidence_state").upsert({
|
| 97 |
+
"user_id": user_id, "dimension_key": dimension_key, "scope": scope, "kind": kind,
|
| 98 |
+
"is_steady": is_steady, "has_ever_been_steady": has_ever_been_steady,
|
| 99 |
+
"last_steady_mean": last_steady_mean,
|
| 100 |
+
"last_steady_mode_label": last_steady_mode_label,
|
| 101 |
+
"last_steady_agreement_ratio": last_steady_agreement_ratio,
|
| 102 |
+
"sample_count": sample_count,
|
| 103 |
+
"last_fired_trigger_type": last_fired_trigger_type,
|
| 104 |
+
"last_fired_session_id": last_fired_session_id,
|
| 105 |
+
"last_fired_sample_count": last_fired_sample_count,
|
| 106 |
+
}, on_conflict="user_id,dimension_key,scope").execute()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _insert_event(user_id, session_id, dimension_key, scope, trigger_type, direction,
|
| 110 |
+
value_at_trigger, previous_value, label_at_trigger, previous_label,
|
| 111 |
+
sample_count, card_copy: dict) -> dict:
|
| 112 |
+
row = {
|
| 113 |
+
"user_id": user_id, "session_id": session_id, "dimension_key": dimension_key,
|
| 114 |
+
"scope": scope, "trigger_type": trigger_type, "direction": direction,
|
| 115 |
+
"value_at_trigger": value_at_trigger, "previous_value": previous_value,
|
| 116 |
+
"label_at_trigger": label_at_trigger, "previous_label": previous_label,
|
| 117 |
+
"sample_count": sample_count, "card_copy_json": json.dumps(card_copy),
|
| 118 |
+
}
|
| 119 |
+
res = supabase_admin.table("dimension_events").insert(row).execute()
|
| 120 |
+
return res.data[0] if res.data else row
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _has_drifted(is_categorical: bool, prior: dict, current: dict, cfg: dict) -> bool:
|
| 124 |
+
if is_categorical:
|
| 125 |
+
prev_label = prior.get("last_steady_mode_label")
|
| 126 |
+
return prev_label is not None and current.get("mode_label") != prev_label
|
| 127 |
+
prev_mean = prior.get("last_steady_mean")
|
| 128 |
+
if prev_mean is None or abs(prev_mean) < 1e-9:
|
| 129 |
+
return False
|
| 130 |
+
relative_change = abs(current["mean"] - prev_mean) / abs(prev_mean)
|
| 131 |
+
return relative_change > cfg["cv_threshold"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _recurring_direction(is_categorical: bool, prior: dict, current: dict, cfg: dict) -> str:
|
| 135 |
+
"""Distinguishes 'back_to_usual' (regained the SAME value) from a fresh
|
| 136 |
+
drift discovered via the regain — same underlying content as `drift`,
|
| 137 |
+
just framed as acknowledging the instability first."""
|
| 138 |
+
return "drift" if _has_drifted(is_categorical, prior, current, cfg) else "back_to_usual"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _check_anomaly(is_categorical: bool, prior: dict, values: list, cfg: dict):
|
| 142 |
+
"""This session's raw value vs. the established baseline. Returns
|
| 143 |
+
(direction, value_or_label) or None. `values` is oldest->newest for this
|
| 144 |
+
dimension+scope — the last entry is this session's own raw value."""
|
| 145 |
+
if not values or values[-1] is None:
|
| 146 |
+
return None
|
| 147 |
+
this_value = values[-1]
|
| 148 |
+
|
| 149 |
+
if is_categorical:
|
| 150 |
+
established_label = prior.get("last_steady_mode_label")
|
| 151 |
+
established_agreement = prior.get("last_steady_agreement_ratio") or 0
|
| 152 |
+
if not established_label or established_agreement < STRONG_MODE_AGREEMENT:
|
| 153 |
+
return None
|
| 154 |
+
if this_value == established_label:
|
| 155 |
+
return None
|
| 156 |
+
return ("contradicts_established", this_value)
|
| 157 |
+
|
| 158 |
+
established_mean = prior.get("last_steady_mean")
|
| 159 |
+
if established_mean is None or abs(established_mean) < 1e-9:
|
| 160 |
+
return None
|
| 161 |
+
band = ANOMALY_BAND_MULTIPLIER * cfg["cv_threshold"]
|
| 162 |
+
relative_change = (this_value - established_mean) / abs(established_mean)
|
| 163 |
+
if relative_change > band:
|
| 164 |
+
return ("up", this_value)
|
| 165 |
+
if relative_change < -band:
|
| 166 |
+
return ("down", this_value)
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _value_str(dimension_key: str, value) -> str:
|
| 171 |
+
if value is None:
|
| 172 |
+
return "n/a"
|
| 173 |
+
try:
|
| 174 |
+
from pipeline.portrait_synthesizer import _format_mean
|
| 175 |
+
return _format_mean(dimension_key, value)
|
| 176 |
+
except Exception:
|
| 177 |
+
return f"{value}"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _phrase_event(dimension_key, label, trigger_type, direction, prior, current, cfg, scope) -> dict:
|
| 181 |
+
"""Deterministic string templates, no LLM call — matches home_feed.py's
|
| 182 |
+
existing f-string convention (e.g. the old build_progress_cards)."""
|
| 183 |
+
is_categorical = cfg["kind"] == "categorical"
|
| 184 |
+
n = current["sample_count"]
|
| 185 |
+
scope_phrase = "" if scope == "overall" else f" in your {scope.replace('_', ' ')} conversations"
|
| 186 |
+
curr_str = current["mode_label"] if is_categorical else _value_str(dimension_key, current["mean"])
|
| 187 |
+
|
| 188 |
+
if trigger_type == "first_time_steady":
|
| 189 |
+
note = (f"Over your last {n} sessions, your {label} has settled into a steady "
|
| 190 |
+
f"pattern — {curr_str}.")
|
| 191 |
+
elif trigger_type == "context_shift":
|
| 192 |
+
note = (f"Your {label} has also settled into a steady pattern specifically"
|
| 193 |
+
f"{scope_phrase} — {curr_str}.")
|
| 194 |
+
elif trigger_type == "recurring" and direction == "back_to_usual":
|
| 195 |
+
note = (f"Your {label} had been varying more than usual for a bit, but it's "
|
| 196 |
+
f"settled back to your usual {curr_str}.")
|
| 197 |
+
elif trigger_type == "recurring": # direction == "drift"
|
| 198 |
+
note = (f"After a stretch of inconsistency, your {label} has settled into a new "
|
| 199 |
+
f"pattern — {curr_str}.")
|
| 200 |
+
elif trigger_type == "drift":
|
| 201 |
+
prev_str = (prior.get("last_steady_mode_label") if is_categorical
|
| 202 |
+
else _value_str(dimension_key, prior.get("last_steady_mean")))
|
| 203 |
+
note = f"Your {label} has shifted — from {prev_str} to {curr_str} over your last {n} sessions."
|
| 204 |
+
else:
|
| 205 |
+
note = f"Your {label} showed a new pattern this session."
|
| 206 |
+
|
| 207 |
+
return {"label": label, "note": note}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _phrase_anomaly(dimension_key, label, direction, prior, this_value, this_label, cfg) -> dict:
|
| 211 |
+
is_categorical = cfg["kind"] == "categorical"
|
| 212 |
+
if is_categorical:
|
| 213 |
+
established = prior.get("last_steady_mode_label", "n/a")
|
| 214 |
+
note = (f"Unlike your usual pattern of {established} {label}, this session was "
|
| 215 |
+
f"{this_label} — worth noting, not necessarily a new pattern yet.")
|
| 216 |
+
else:
|
| 217 |
+
established = _value_str(dimension_key, prior.get("last_steady_mean"))
|
| 218 |
+
this_str = _value_str(dimension_key, this_value)
|
| 219 |
+
word = "higher" if direction == "up" else "lower"
|
| 220 |
+
note = (f"This session your {label} was notably {word} than usual — {this_str} vs "
|
| 221 |
+
f"your typical {established}. Worth noting, not necessarily a new pattern yet.")
|
| 222 |
+
return {"label": label, "note": note}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _check_dimension(user_id, session_id, dimension_key, scope, parsed, cfg, config_dict,
|
| 226 |
+
allowed_triggers=None) -> list:
|
| 227 |
+
"""Returns the list of newly fired events for this (dimension, scope) this run."""
|
| 228 |
+
is_categorical = cfg["kind"] == "categorical"
|
| 229 |
+
label = cfg["label"]
|
| 230 |
+
values = _scoped_values(parsed, dimension_key, scope, config_dict)
|
| 231 |
+
current = compute_evidence(dimension_key, values, config_dict)
|
| 232 |
+
|
| 233 |
+
prior = _fetch_state(user_id, dimension_key, scope)
|
| 234 |
+
prior_is_steady = bool(prior.get("is_steady"))
|
| 235 |
+
has_ever_been_steady = bool(prior.get("has_ever_been_steady"))
|
| 236 |
+
last_fired_sample_count = prior.get("last_fired_sample_count")
|
| 237 |
+
|
| 238 |
+
def allowed(trigger_type):
|
| 239 |
+
return allowed_triggers is None or trigger_type in allowed_triggers
|
| 240 |
+
|
| 241 |
+
cooldown_clear = (
|
| 242 |
+
last_fired_sample_count is None or
|
| 243 |
+
current["sample_count"] - last_fired_sample_count >= COOLDOWN_SESSIONS
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
candidates = [] # list of (trigger_type, direction)
|
| 247 |
+
if current["is_steady"] and not prior_is_steady:
|
| 248 |
+
if not has_ever_been_steady:
|
| 249 |
+
candidates.append(("first_time_steady" if scope == "overall" else "context_shift", None))
|
| 250 |
+
else:
|
| 251 |
+
candidates.append(("recurring", _recurring_direction(is_categorical, prior, current, cfg)))
|
| 252 |
+
elif current["is_steady"] and prior_is_steady:
|
| 253 |
+
if _has_drifted(is_categorical, prior, current, cfg):
|
| 254 |
+
candidates.append(("drift", None))
|
| 255 |
+
# steady -> not-steady, not-steady -> not-steady: no candidate from this branch.
|
| 256 |
+
|
| 257 |
+
anomaly = None
|
| 258 |
+
if scope == "overall" and prior_is_steady:
|
| 259 |
+
anomaly = _check_anomaly(is_categorical, prior, values, cfg)
|
| 260 |
+
if anomaly:
|
| 261 |
+
candidates.append(("anomaly", anomaly[0]))
|
| 262 |
+
|
| 263 |
+
fired = []
|
| 264 |
+
for trigger_type, direction in candidates:
|
| 265 |
+
if not allowed(trigger_type) or not cooldown_clear:
|
| 266 |
+
continue
|
| 267 |
+
if trigger_type == "anomaly":
|
| 268 |
+
_, anomaly_value_or_label = anomaly
|
| 269 |
+
this_value = None if is_categorical else anomaly_value_or_label
|
| 270 |
+
this_label = anomaly_value_or_label if is_categorical else None
|
| 271 |
+
copy = _phrase_anomaly(dimension_key, label, direction, prior, this_value, this_label, cfg)
|
| 272 |
+
fired.append(_insert_event(
|
| 273 |
+
user_id, session_id, dimension_key, scope, "anomaly", direction,
|
| 274 |
+
this_value, prior.get("last_steady_mean"), this_label, prior.get("last_steady_mode_label"),
|
| 275 |
+
current["sample_count"], copy,
|
| 276 |
+
))
|
| 277 |
+
else:
|
| 278 |
+
copy = _phrase_event(dimension_key, label, trigger_type, direction, prior, current, cfg, scope)
|
| 279 |
+
fired.append(_insert_event(
|
| 280 |
+
user_id, session_id, dimension_key, scope, trigger_type, direction,
|
| 281 |
+
current.get("mean"), prior.get("last_steady_mean"),
|
| 282 |
+
current.get("mode_label"), prior.get("last_steady_mode_label"),
|
| 283 |
+
current["sample_count"], copy,
|
| 284 |
+
))
|
| 285 |
+
|
| 286 |
+
# Always update state — freeze last_steady_* while not steady, so a later
|
| 287 |
+
# drift/anomaly check compares against the last REAL steady baseline, not
|
| 288 |
+
# noise from an unsteady stretch.
|
| 289 |
+
if current["is_steady"]:
|
| 290 |
+
last_steady_mean = current.get("mean")
|
| 291 |
+
last_steady_mode_label = current.get("mode_label")
|
| 292 |
+
last_steady_agreement_ratio = current.get("agreement_ratio")
|
| 293 |
+
else:
|
| 294 |
+
last_steady_mean = prior.get("last_steady_mean")
|
| 295 |
+
last_steady_mode_label = prior.get("last_steady_mode_label")
|
| 296 |
+
last_steady_agreement_ratio = prior.get("last_steady_agreement_ratio")
|
| 297 |
+
|
| 298 |
+
any_fired = len(fired) > 0
|
| 299 |
+
_upsert_state(
|
| 300 |
+
user_id, dimension_key, scope, cfg["kind"],
|
| 301 |
+
current["is_steady"], has_ever_been_steady or current["is_steady"],
|
| 302 |
+
last_steady_mean, last_steady_mode_label, last_steady_agreement_ratio,
|
| 303 |
+
current["sample_count"],
|
| 304 |
+
last_fired_trigger_type=(fired[-1]["trigger_type"] if any_fired else prior.get("last_fired_trigger_type")),
|
| 305 |
+
last_fired_session_id=(session_id if any_fired else prior.get("last_fired_session_id")),
|
| 306 |
+
last_fired_sample_count=(current["sample_count"] if any_fired else last_fired_sample_count),
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
return fired
|
|
@@ -2,31 +2,21 @@ import { useState, useEffect } from "react"
|
|
| 2 |
import api from "../lib/api"
|
| 3 |
import Reveal, { RevealItem } from "./Reveal"
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
//
|
| 8 |
-
//
|
| 9 |
-
//
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
how_it_may_land: { color: "#22d3ee", label: "How it may land" },
|
| 20 |
-
progress: { color: "#5b9cf6", label: "Progress" },
|
| 21 |
-
still_forming: { color: "#6b6888", label: "Still forming" },
|
| 22 |
-
session_observation: { color: "#818cf8", label: "From your last session" },
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
function cardVisual(card) {
|
| 26 |
-
if (card.type === "observation") {
|
| 27 |
-
return FRAMING_CONFIG[card.framing] || FRAMING_CONFIG.observation
|
| 28 |
-
}
|
| 29 |
-
return TYPE_CONFIG[card.type] || { color: "#8b89aa", label: card.type }
|
| 30 |
}
|
| 31 |
|
| 32 |
function DismissButton({ onDismiss }) {
|
|
@@ -40,82 +30,120 @@ function DismissButton({ onDismiss }) {
|
|
| 40 |
)
|
| 41 |
}
|
| 42 |
|
| 43 |
-
function
|
| 44 |
-
const cfg = cardVisual(card)
|
| 45 |
-
const title = card.type === "session_observation"
|
| 46 |
-
? (card.signal || "").replace(/_/g, " ") || cfg.label
|
| 47 |
-
: (card.label || cfg.label)
|
| 48 |
-
|
| 49 |
return (
|
| 50 |
-
<RevealItem index={i}>
|
| 51 |
<div style={{
|
| 52 |
padding: "14px 16px", borderRadius: 10,
|
| 53 |
background: "#151922", border: "1px solid #1e2438",
|
| 54 |
-
borderLeft: `3px solid ${
|
|
|
|
|
|
|
| 55 |
}}>
|
| 56 |
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 6 }}>
|
| 57 |
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
| 58 |
<span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff", textTransform: "capitalize" }}>
|
| 59 |
{title}
|
| 60 |
</span>
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
</div>
|
| 67 |
-
<DismissButton onDismiss={
|
| 68 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
</p>
|
| 74 |
)}
|
| 75 |
|
| 76 |
-
{card.
|
| 77 |
-
<div style={{
|
| 78 |
-
|
| 79 |
-
<div
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
</div>
|
| 87 |
)}
|
| 88 |
|
| 89 |
-
{
|
| 90 |
-
<
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
)}
|
| 94 |
|
| 95 |
-
{card.
|
| 96 |
-
<p style={{ margin: 0, fontSize:
|
| 97 |
-
|
| 98 |
</p>
|
| 99 |
)}
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
</p>
|
| 106 |
)}
|
| 107 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</RevealItem>
|
| 109 |
)
|
| 110 |
}
|
| 111 |
|
|
|
|
|
|
|
| 112 |
export default function HomeView({ active }) {
|
| 113 |
const [data, setData] = useState(null)
|
| 114 |
const [loading, setLoading] = useState(true)
|
|
|
|
| 115 |
|
| 116 |
useEffect(() => {
|
| 117 |
if (!active) return
|
| 118 |
setLoading(true)
|
|
|
|
| 119 |
api.get("/api/home")
|
| 120 |
.then(res => setData(res.data))
|
| 121 |
.catch(console.error)
|
|
@@ -152,6 +180,11 @@ export default function HomeView({ active }) {
|
|
| 152 |
}
|
| 153 |
|
| 154 |
const cards = data.cards || []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
return (
|
| 157 |
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
|
@@ -176,9 +209,19 @@ export default function HomeView({ active }) {
|
|
| 176 |
) : (
|
| 177 |
<Reveal delay={80}>
|
| 178 |
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 179 |
-
{
|
| 180 |
-
<HomeCard key={card.card_key} card={card} i={i}
|
|
|
|
|
|
|
| 181 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
</div>
|
| 183 |
</Reveal>
|
| 184 |
)}
|
|
|
|
| 2 |
import api from "../lib/api"
|
| 3 |
import Reveal, { RevealItem } from "./Reveal"
|
| 4 |
|
| 5 |
+
// Visual identity per trigger type — independent of the (not-yet-built) per-
|
| 6 |
+
// dimension color palette on the You page; this is scoped to what Home's
|
| 7 |
+
// dimension-event cards need: a quick visual read on WHAT KIND of thing
|
| 8 |
+
// happened (a first discovery vs. a shift vs. something to just note), not
|
| 9 |
+
// WHICH of the 15 dimensions it is.
|
| 10 |
+
const TRIGGER_CONFIG = {
|
| 11 |
+
first_time_steady: { color: "#34d399", label: "New pattern" },
|
| 12 |
+
context_shift: { color: "#22d3ee", label: "Context shift" },
|
| 13 |
+
drift: { color: "#f59e0b", label: "Shifted" },
|
| 14 |
+
recurring: { color: "#a78bfa", label: "Recurring" },
|
| 15 |
+
anomaly: { color: "#fb7185", label: "Worth noting" },
|
| 16 |
}
|
| 17 |
|
| 18 |
+
function recurringLabel(direction) {
|
| 19 |
+
return direction === "back_to_usual" ? "Back to usual" : "New pattern"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
function DismissButton({ onDismiss }) {
|
|
|
|
| 30 |
)
|
| 31 |
}
|
| 32 |
|
| 33 |
+
function CardShell({ color, title, badge, faded, onDismiss, children }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return (
|
|
|
|
| 35 |
<div style={{
|
| 36 |
padding: "14px 16px", borderRadius: 10,
|
| 37 |
background: "#151922", border: "1px solid #1e2438",
|
| 38 |
+
borderLeft: `3px solid ${color}`,
|
| 39 |
+
opacity: faded ? 0.45 : 1,
|
| 40 |
+
transition: "opacity 0.3s ease",
|
| 41 |
}}>
|
| 42 |
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 6 }}>
|
| 43 |
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
| 44 |
<span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff", textTransform: "capitalize" }}>
|
| 45 |
{title}
|
| 46 |
</span>
|
| 47 |
+
{badge && (
|
| 48 |
+
<span style={{ fontSize: 11, color, fontWeight: 600,
|
| 49 |
+
background: `${color}15`, border: `1px solid ${color}30`,
|
| 50 |
+
borderRadius: 20, padding: "3px 10px", whiteSpace: "nowrap" }}>
|
| 51 |
+
{badge}
|
| 52 |
+
</span>
|
| 53 |
+
)}
|
| 54 |
</div>
|
| 55 |
+
<DismissButton onDismiss={onDismiss} />
|
| 56 |
</div>
|
| 57 |
+
{children}
|
| 58 |
+
</div>
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
|
| 62 |
+
function SessionRecapCard({ card, faded, onDismiss }) {
|
| 63 |
+
const dateLabel = card.date ? new Date(card.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : ""
|
| 64 |
+
return (
|
| 65 |
+
<CardShell color="#818cf8" title="Session recap"
|
| 66 |
+
badge={[card.context?.replace(/_/g, " "), dateLabel].filter(Boolean).join(" · ")}
|
| 67 |
+
faded={faded} onDismiss={onDismiss}>
|
| 68 |
+
{card.conversation_summary && (
|
| 69 |
+
<p style={{ margin: "0 0 10px", fontSize: 13, color: "#c4c2d8", lineHeight: 1.65 }}>
|
| 70 |
+
{card.conversation_summary}
|
| 71 |
</p>
|
| 72 |
)}
|
| 73 |
|
| 74 |
+
{card.observations?.length > 0 && (
|
| 75 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: card.coaching_suggestions?.length ? 10 : 0 }}>
|
| 76 |
+
{card.observations.map((obs, i) => (
|
| 77 |
+
<div key={i}>
|
| 78 |
+
<p style={{ margin: 0, fontSize: 13, color: "#c4c2d8", lineHeight: 1.6 }}>
|
| 79 |
+
{obs.observation}
|
| 80 |
+
</p>
|
| 81 |
+
{obs.resonance_prompt && (
|
| 82 |
+
<p style={{ margin: "4px 0 0", fontSize: 12, color: "#6b6888", fontStyle: "italic", lineHeight: 1.5 }}>
|
| 83 |
+
💭 {obs.resonance_prompt}
|
| 84 |
+
</p>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
))}
|
| 88 |
</div>
|
| 89 |
)}
|
| 90 |
|
| 91 |
+
{card.coaching_suggestions?.length > 0 && (
|
| 92 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 4 }}>
|
| 93 |
+
{card.coaching_suggestions.map((s, i) => (
|
| 94 |
+
<p key={i} style={{ margin: 0, fontSize: 12, color: "#8b89aa", lineHeight: 1.55 }}>
|
| 95 |
+
<span style={{ color: "#a5a3c2", fontWeight: 600 }}>{s.area}: </span>
|
| 96 |
+
{s.suggestion}
|
| 97 |
+
</p>
|
| 98 |
+
))}
|
| 99 |
+
</div>
|
| 100 |
)}
|
| 101 |
|
| 102 |
+
{card.notable_pattern && (
|
| 103 |
+
<p style={{ margin: "10px 0 0", fontSize: 12, color: "#4a4865", lineHeight: 1.5, borderTop: "1px solid #1e2438", paddingTop: 8 }}>
|
| 104 |
+
{card.notable_pattern}
|
| 105 |
</p>
|
| 106 |
)}
|
| 107 |
+
</CardShell>
|
| 108 |
+
)
|
| 109 |
+
}
|
| 110 |
|
| 111 |
+
function DimensionEventCard({ card, faded, onDismiss }) {
|
| 112 |
+
const trig = TRIGGER_CONFIG[card.trigger_type] || { color: "#8b89aa", label: card.trigger_type }
|
| 113 |
+
const badge = card.trigger_type === "recurring" ? recurringLabel(card.direction) : trig.label
|
| 114 |
+
return (
|
| 115 |
+
<CardShell color={trig.color} title={card.label} badge={badge} faded={faded} onDismiss={onDismiss}>
|
| 116 |
+
{card.note && (
|
| 117 |
+
<p style={{ margin: 0, fontSize: 13, color: "#c4c2d8", lineHeight: 1.65 }}>
|
| 118 |
+
{card.note}
|
| 119 |
</p>
|
| 120 |
)}
|
| 121 |
+
</CardShell>
|
| 122 |
+
)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function HomeCard({ card, i, faded, onDismiss }) {
|
| 126 |
+
const dismiss = () => onDismiss(card.card_key)
|
| 127 |
+
return (
|
| 128 |
+
<RevealItem index={i}>
|
| 129 |
+
{card.type === "dimension_event"
|
| 130 |
+
? <DimensionEventCard card={card} faded={faded} onDismiss={dismiss} />
|
| 131 |
+
: <SessionRecapCard card={card} faded={faded} onDismiss={dismiss} />}
|
| 132 |
</RevealItem>
|
| 133 |
)
|
| 134 |
}
|
| 135 |
|
| 136 |
+
const VISIBLE_COUNT = 7
|
| 137 |
+
|
| 138 |
export default function HomeView({ active }) {
|
| 139 |
const [data, setData] = useState(null)
|
| 140 |
const [loading, setLoading] = useState(true)
|
| 141 |
+
const [expanded, setExpanded] = useState(false)
|
| 142 |
|
| 143 |
useEffect(() => {
|
| 144 |
if (!active) return
|
| 145 |
setLoading(true)
|
| 146 |
+
setExpanded(false)
|
| 147 |
api.get("/api/home")
|
| 148 |
.then(res => setData(res.data))
|
| 149 |
.catch(console.error)
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
const cards = data.cards || []
|
| 183 |
+
// Backend returns the complete, newest-first, dismissed-filtered list —
|
| 184 |
+
// pagination is purely a frontend rendering concern: 7 visible + an 8th
|
| 185 |
+
// shown faded as a teaser, "Show more" reveals the rest uncapped.
|
| 186 |
+
const hasOverflow = cards.length > VISIBLE_COUNT
|
| 187 |
+
const visibleCards = expanded ? cards : cards.slice(0, VISIBLE_COUNT + 1)
|
| 188 |
|
| 189 |
return (
|
| 190 |
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
|
|
|
| 209 |
) : (
|
| 210 |
<Reveal delay={80}>
|
| 211 |
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
| 212 |
+
{visibleCards.map((card, i) => (
|
| 213 |
+
<HomeCard key={card.card_key} card={card} i={i}
|
| 214 |
+
faded={!expanded && hasOverflow && i === VISIBLE_COUNT}
|
| 215 |
+
onDismiss={handleDismiss} />
|
| 216 |
))}
|
| 217 |
+
{!expanded && hasOverflow && (
|
| 218 |
+
<button onClick={() => setExpanded(true)}
|
| 219 |
+
style={{ alignSelf: "center", marginTop: 4, background: "none",
|
| 220 |
+
border: "none", cursor: "pointer", color: "#7c8fd6",
|
| 221 |
+
fontSize: 13, fontWeight: 600, padding: "8px 0" }}>
|
| 222 |
+
Show more
|
| 223 |
+
</button>
|
| 224 |
+
)}
|
| 225 |
</div>
|
| 226 |
</Reveal>
|
| 227 |
)}
|