Harsh200415 Claude Sonnet 5 commited on
Commit
9ceaaad
·
1 Parent(s): dbbf495

Home feed v2: 15-dimension evidence engine + trigger-detection + interleaved feed

Browse files

Replaces 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 CHANGED
@@ -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 SIGNAL_EVIDENCE_CONFIG, compute_signal_evidence, extract_value
 
 
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 = compute_signal_evidence(signal_key, values) # filters None internally
 
207
  non_none = [v for v in values if v is not None] # chronological, oldest→newest
208
- # recent-vs-established only computed here for a SINGLE context (see
209
- # by_context below) computing it on the cross-context pool is
210
- # misleading: a run of e.g. social sessions can drag the "recent"
211
- # average down for reasons that have nothing to do with a genuine
212
- # behavioral shift, just a different conversation type happening
213
- # recently. Self-relative framing has to stay within one context.
214
- if compute_shift and ev["is_steady"] and len(non_none) >= 3:
 
 
 
 
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
- portrait = portrait_synth.synthesize(evidence, blind_spots, session_count)
 
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
- label = SIGNAL_EVIDENCE_CONFIG[signal_key]["label"]
 
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
- "mean": ev["mean"],
 
 
 
 
 
 
1367
  "sample_count": ev["sample_count"],
1368
- "recent_mean": ev["recent_mean"],
1369
- "shift_pct": ev["shift_pct"],
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]["mean"]
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.build_strength_cards(evidence, llm_notes_by_signal, dismissed)
1449
- cards += home_feed.build_observation_cards(evidence, llm_notes_by_signal, dismissed)
1450
- cards += home_feed.build_how_it_may_land_cards(portrait_llm, dismissed)
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": n, "cards": cards}
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] = compute_signal_evidence(signal_key, values)
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
 
backend/pipeline/evidence_gate.py CHANGED
@@ -1,62 +1,159 @@
1
  import numpy as np
 
2
 
3
- # Per-signal (min_samples, cv_threshold) — see roadmap/product_decisions memory for
4
- # the reasoning behind each threshold. These are starting defaults with no real
5
  # user data yet; expect to tune once sessions accumulate.
 
 
 
 
 
 
 
 
 
6
  SIGNAL_EVIDENCE_CONFIG = {
7
  "talk_ratio": {
8
- "min_samples": 5,
 
9
  "cv_threshold": 0.20,
10
  "extract": lambda sig: sig["talk_ratio"]["user_ratio"],
11
  "label": "talk-share",
12
  },
13
- "questions": {
 
14
  "min_samples": 5,
15
  "cv_threshold": 0.35,
16
- "extract": lambda sig: sig["questions"]["user_questions_asked"],
17
  "label": "curiosity",
18
  },
19
- "speech_rate": {
20
- "min_samples": 5,
21
- "cv_threshold": 0.15,
22
- "extract": lambda sig: sig["speech_rate"]["overall_wpm"],
23
- "label": "pace",
 
24
  },
25
- "response_latency": {
26
- "min_samples": 5,
27
- "cv_threshold": 0.40,
28
- "extract": lambda sig: sig["pauses"]["response_latency"]["mean_s"],
29
- "label": "pauses",
 
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
- "question_impact": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "drive_vs_follow": {
 
50
  "min_samples": 6,
51
- "cv_threshold": 0.35,
52
- "extract": lambda sig: sig["drive_vs_follow"]["drive_score"],
53
- "label": "conversational drive",
 
 
54
  },
55
- "building_on_others": {
 
56
  "min_samples": 6,
57
  "cv_threshold": 0.40,
58
- "extract": lambda sig: sig["building_on_others"]["building_on_rate"],
59
- "label": "building on others",
 
 
60
  },
61
  }
62
 
@@ -65,15 +162,23 @@ SIGNAL_EVIDENCE_CONFIG = {
65
  ROLLING_WINDOW = 10
66
 
67
 
68
- def compute_signal_evidence(signal_key: str, historical_values: list) -> dict:
69
- """Given all past values of one signal (oldest→newest) for a user+context,
70
- return an evidence summary: sample_count, mean, cv, is_steady.
 
 
 
 
 
 
 
 
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[signal_key]
76
- # Some signals (e.g. question_impact) legitimately have no value for a session
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 stored
105
- in sessions.signals_json)."""
106
- return SIGNAL_EVIDENCE_CONFIG[signal_key]["extract"](signals)
 
 
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)
backend/pipeline/home_feed.py CHANGED
@@ -1,136 +1,76 @@
1
- """Home feed card builders — replaces the old mirror_feed_synthesizer.py LLM
2
- free-form feed. Every function here is a pure function over already-computed
3
- data (evidence, portrait LLM notes, parsed sessions, dismissed keys) — zero new
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
- Card types, per CLAUDE.md's Home = "stream of insight cards" description:
8
- strength, observation (growth_area + observation framings unified), progress,
9
- still_forming, and a zero-LLM session-level observation card. Every card is
10
- self-relative, carries its evidence, and has a stable `card_key` for dismissal.
 
 
 
 
 
 
 
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 build_strength_cards(evidence: dict, llm_notes_by_signal: dict, dismissed: set) -> list:
 
 
 
 
26
  cards = []
27
- for signal_key, ev in evidence.get("overall", {}).items():
28
- if not ev.get("is_steady"):
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": "strength",
38
  "card_key": card_key,
39
- "signal_key": signal_key,
40
- "label": SIGNAL_EVIDENCE_CONFIG[signal_key]["label"],
41
- "note": llm.get("note", ""),
42
- "mean": ev["mean"],
43
- "sample_count": ev["sample_count"],
 
 
44
  })
45
  return cards
46
 
47
 
48
- def build_observation_cards(evidence: dict, llm_notes_by_signal: dict, dismissed: set) -> list:
49
- """growth_area and observation framings share one card type and one
50
- dismissal key dismissing commentary on a signal should hold even if the
51
- portrait LLM later flips which of the two framings it uses for it."""
 
 
 
52
  cards = []
53
- for signal_key, ev in evidence.get("overall", {}).items():
54
- if not ev.get("is_steady"):
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": "observation",
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
- "signal_key": signal_key,
94
- "label": SIGNAL_EVIDENCE_CONFIG[signal_key]["label"],
95
- "sample_count": ev["sample_count"],
96
- "min_needed": ev["min_samples_required"],
97
- "_remaining": remaining,
 
 
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/pipeline/insight_generator.py CHANGED
@@ -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
- signal_current_value = {
102
- "talk_ratio": prepared["talk_ratio_user"],
103
- "questions": prepared["user_questions_asked"],
104
- "speech_rate": prepared["speech_rate_wpm"],
105
- "response_latency": prepared["avg_response_latency_s"],
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
- current = signal_current_value.get(sig_key)
116
- if sig_evidence["is_steady"] and current is not None:
 
 
 
 
 
 
 
 
 
 
 
 
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:
backend/pipeline/portrait_synthesizer.py CHANGED
@@ -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": (100, "{:.0f}", "% of speaking time"),
11
- "questions": (1, "{:.1f}", " questions asked per session"),
12
- "speech_rate": (1, "{:.0f}", " words per minute"),
13
- "response_latency": (1, "{:.1f}", "s before responding"),
14
- "hedging": (1, "{:.1f}", " hedging phrases per 100 words"),
15
- "directness": (1, "{:.1f}", " direct/assertive phrases per 100 words"),
16
- "question_impact": (100, "{:.0f}", "% of your questions picked up by the room"),
17
- "drive_vs_follow": (100, "{:.0f}", "% drive score (higher = initiates more, lower = follows more)"),
18
- "building_on_others": (100, "{:.0f}", "% of your turns build on someone else's point"),
 
 
 
 
19
  }
20
 
21
 
22
- def _format_mean(signal_key: str, mean: float) -> str:
 
 
 
 
23
  scale, fmt, unit = _SIGNAL_FORMAT[signal_key]
24
- return fmt.format(mean * scale) + unit
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) -> dict:
 
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]["mean"]
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
- desc = f" {label} ({signal_key}): established at {_format_mean(signal_key, ev['mean'])}, " \
 
 
77
  f"based on {ev['sample_count']} sessions"
78
  signal_lines.append(desc)
79
 
80
- wants_how_it_may_land = "question_impact" in steady_overall
 
 
 
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, mean)}"
87
- for ctx, mean in by_ctx.items()
88
  )
89
  context_lines.append(f" {label} ({signal_key}) — {parts}")
90
 
91
  how_it_may_land_task = (
92
- "\nALSO: \"question follow-through\" (question_impact) is established. Write ONE "
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": "question_impact", "note": "one sentence"}\n ]'
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
 
backend/pipeline/signal_extractor.py CHANGED
@@ -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": self._compute_interruptions(user_segs, other_segs),
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
- text = turn["text"].lower()
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, questions) -> dict:
338
- """Composite of three already-computed signals — a single proxy (e.g. just
339
- "turns not preceded by a question") is too thin on its own; this reuses the
340
- same inputs dimension_scorer.py's _analyze_driver already treats as a
341
- reasonable way to operationalize "who drove" in this codebase."""
 
 
 
 
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.4 * initiation_fraction + 0.3 * turn_length_asymmetry + 0.3 * question_asymmetry, 3
 
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
- prev = all_turns[i - 1] if i > 0 else None
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
- "building_on_rate": round(building_on_count / total_user_turns, 3) if total_user_turns > 0 else 0,
 
 
 
 
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. Higher = richer vocabulary."""
 
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) < 10:
613
  return {"type_token_ratio": None, "unique_words": 0, "total_words": len(words)}
614
 
615
- unique = len(set(words))
616
- total = len(words)
617
- ttr = round(unique / total, 3)
618
 
619
  return {
620
  "type_token_ratio": ttr,
621
  "unique_words": unique,
622
- "total_words": total
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:
backend/pipeline/trigger_detector.py ADDED
@@ -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
frontend/src/components/HomeView.jsx CHANGED
@@ -2,31 +2,21 @@ import { useState, useEffect } from "react"
2
  import api from "../lib/api"
3
  import Reveal, { RevealItem } from "./Reveal"
4
 
5
- const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"
6
-
7
- // Mirrors ProfileView's FRAMING_CONFIG growth_area/observation framings of a
8
- // steady signal render with this palette; other card types get their own fixed
9
- // color below. Kept as a separate copy rather than a shared import since the
10
- // two views are deliberately allowed to diverge visually over time.
11
- const FRAMING_CONFIG = {
12
- strength: { color: "#34d399", label: "Strength" },
13
- growth_area: { color: "#fb923c", label: "Growth area" },
14
- observation: { color: "#818cf8", label: "Observation" },
 
15
  }
16
 
17
- const TYPE_CONFIG = {
18
- strength: { color: "#34d399", label: "Strength" },
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 HomeCard({ card, i, onDismiss }) {
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 ${cfg.color}`,
 
 
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
- <span style={{ fontSize: 11, color: cfg.color, fontWeight: 600,
62
- background: `${cfg.color}15`, border: `1px solid ${cfg.color}30`,
63
- borderRadius: 20, padding: "3px 10px", whiteSpace: "nowrap" }}>
64
- {cfg.label}{card.type === "progress" && (card.direction === "up" ? " ↑" : "")}
65
- </span>
 
 
66
  </div>
67
- <DismissButton onDismiss={() => onDismiss(card.card_key)} />
68
  </div>
 
 
 
 
69
 
70
- {card.note && (
71
- <p style={{ margin: "0 0 8px", fontSize: 13, color: "#c4c2d8", lineHeight: 1.65 }}>
72
- {card.note}
 
 
 
 
 
 
73
  </p>
74
  )}
75
 
76
- {card.type === "still_forming" && (
77
- <div style={{ marginTop: 4 }}>
78
- <div style={{ height: 3, background: "#1e2438", borderRadius: 2 }}>
79
- <div style={{ height: "100%", borderRadius: 2,
80
- width: `${Math.min(100, Math.round((card.sample_count / card.min_needed) * 100))}%`,
81
- background: "#3a3a52" }} />
82
- </div>
83
- <p style={{ margin: "8px 0 0", fontSize: 11, color: "#4a4865" }}>
84
- {card.sample_count} of {card.min_needed} sessions
85
- </p>
 
 
 
 
86
  </div>
87
  )}
88
 
89
- {(card.type === "strength" || card.type === "observation") && card.sample_count != null && (
90
- <p style={{ margin: 0, fontSize: 11, color: "#4a4865" }}>
91
- Based on {card.sample_count} sessions
92
- </p>
 
 
 
 
 
93
  )}
94
 
95
- {card.type === "progress" && card.context && (
96
- <p style={{ margin: 0, fontSize: 11, color: "#4a4865" }}>
97
- In your {card.context.replace(/_/g, " ")} conversations
98
  </p>
99
  )}
 
 
 
100
 
101
- {card.type === "session_observation" && card.resonance_prompt && (
102
- <p style={{ margin: "8px 0 0", fontSize: 12, color: "#6b6888",
103
- fontStyle: "italic", lineHeight: 1.5 }}>
104
- 💭 {card.resonance_prompt}
 
 
 
 
105
  </p>
106
  )}
107
- </div>
 
 
 
 
 
 
 
 
 
 
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
- {cards.map((card, i) => (
180
- <HomeCard key={card.card_key} card={card} i={i} onDismiss={handleDismiss} />
 
 
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
  )}