import InfoHint from "./InfoHint.jsx"; import MiniLineChart from "./MiniLineChart.jsx"; /** * UserProfileCard — hero panel showing all 12 behavioral facets for one user. * * Layout: * ╭─────────────────────────────────────────────────────────────────╮ * │ COGNITIVE PROFILE · demo_user │ * │ │ * │ ┌─ HERO ─────┐ ┌─ HERO ──────┐ ┌─ HERO ──────┐ │ * │ │ Offer │ │ Decision │ │ Top topic │ │ * │ │ readiness │ │ stage │ │ │ │ * │ │ 0.96 │ │ Action- │ │ general │ │ * │ │ Ready │ │ ready │ │ score 0.87 │ │ * │ └────────────┘ └─────────────┘ └─────────────┘ │ * │ │ * │ Facet grid: Intent · Engagement · Format · Structured · │ * │ Clarity · Friction · Positive · Momentum · │ * │ Learning confidence │ * ╰─────────────────────────────────────────────────────────────────╯ * * Each facet badge: * - small uppercase label * - value (the categorical or numeric punchline) * - tiny score bar or footnote * * All facets are *behavioral*, never psychological. The card explicitly * surfaces things like "prefers structured layouts" or "in evaluation stage", * never "user is anxious" or "user is risk-averse". */ export default function UserProfileCard({ profile, userLabel, dailyActivity = null }) { if (!profile) return null; const readyTier = profile.offer_readiness_score >= 0.7 ? "ready" : profile.offer_readiness_score >= 0.5 ? "likely" : profile.offer_readiness_score >= 0.3 ? "nurture" : "early"; return (
Cognitive profile

{profile.display_name || userLabel || profile.user_id_hash}

{profile.user_id_hash}
Bandit pulls {profile.total_bandit_pulls} Tracked topics {profile.tracked_topics} Last seen {relativeTime(profile.last_activity_ts)}
{/* Per-user activity timeline */} {dailyActivity && dailyActivity.length > 0 && (
Activity timeline This user's turn count per day over the selected window. Tall bars = an intense session; gaps = days they didn't engage. Useful for spotting the cadence ("morning checker", "weekend deep-divers"). {dailyActivity.reduce((s, d) => s + (d.count || 0), 0)} turns in window ·{" "} {dailyActivity.length} active day{dailyActivity.length === 1 ? "" : "s"}
({ date: d.date, value: d.count }))} width={640} height={70} color="#3b82c4" yLabel="turns/day" formatValue={(v) => `${v} turn${v === 1 ? "" : "s"}`} />
)} {/* Three hero metrics */}
Stage-gated composite. raw = 0.35·interest + 0.30·recency + 0.20·engagement + 0.15·stage, then readiness = raw × decision_stage_score. The multiplicative gate means a user in Awareness can't be Ready no matter how engaged.
Ready ≥ 0.70 · Likely ≥ 0.50 · Nurture ≥ 0.30 · Too early < 0.30. } /> Inferred from the last 10 intents. Precedence: Support-needed (≥40% Troubleshooting), Action-ready (≥40% Decision/Recommendation), Evaluation (≥40% Comparison/Evaluation), Awareness (≥50% Definitional), else Exploration. } /> The topic with the highest interest_score from ape_user_topic_interest. Score blends 40% frequency, 25% recency, 25% engagement, 10% follow-up depth — all derived from the user's last 30 days of activity. } />
{/* Facet grid — the other 9 facets */}
The intent the user fires most often. Drawn from ape_turn_record.intent counts. A user with mostly Decision is action-oriented; mostly Definitional means they're learning basics.} /> How deep they go on a single topic. High = max 30-day count ≥ 8. Medium ≥ 3. Low < 3. A casual one-off question is Low; 12 turns on retirement is High.} /> The strategy that wins for this user — picked by argmax(avg_reward × min(count/5, 1)). The count damping prevents a single high-reward pull from winning.} /> Tables/bullets vs prose. We weight reward by pull count for each group; a 0.20 gap flips the label. Structured means tables/cards/steps. Paragraph means prose/analogy. Mixed = within 0.20 of each other.} /> How often the user signals confusion. Counts format_change_request, reask_same_question. High ≥ 5 signals — consider simpler defaults for them.} /> 3 ? "warn" : "ok"} footnote="thumbs_down / format_change / correction" info={<>Hard negative signals: thumbs_down, format_change_request, content_correction, plus turns with negative normalized_reward. High friction users are candidates for instruction refinement on their dominant topics.} /> 0 ? "pos" : "neutral"} footnote="thumbs_up / format_praise / it_worked / deeper_question" info={<>Hard positive signals: thumbs_up, format_praise_explicit, it_worked_statement, deeper_question, plus turns with positive normalized_reward. High count = the system is working for this user.} /> Are they hot right now? Compares per-day rate in the last 3 days vs 30-day baseline. High = ≥1.5× — they're accelerating. Medium ≥ 0.7×. Low means they're going quiet.} /> How much data backs this user's personalization. High = ≥20 total bandit pulls. Medium ≥ 8. Low = still cold-start — treat preferences as tentative.} />
Behavioral facets only — no psychological labels, no raw queries. All scores derive from ape_turn_record, ape_user_bandit_state, and ape_user_topic_interest.
); } // ---------- Sub-components ---------- function HeroTile({ label, headline, sub, score, tier, info }) { return (
{label} {info && {info}}
{headline}
{score != null && (
{fmt(score)}
)} {sub &&
{sub}
}
); } function FacetTile({ label, value, score, footnote, tone = "neutral", info }) { return (
{label} {info && {info}}
{value ?? "—"}
{score != null && (
)} {footnote &&
{footnote}
}
); } // ---------- Helpers ---------- function tierForStage(stage) { switch (stage) { case "Action-ready": return "ready"; case "Evaluation": return "likely"; case "Exploration": return "nurture"; case "Awareness": return "early"; case "Support-needed": return "warn"; default: return "info"; } } function formatIntentMix(dist) { if (!dist) return "—"; const entries = Object.entries(dist).slice(0, 3); if (!entries.length) return "—"; const total = Object.values(dist).reduce((a, b) => a + b, 0) || 1; return entries.map(([k, v]) => `${k} ${Math.round(100 * v / total)}%`).join(" · "); } function pretty(s) { if (!s) return s; return String(s).replace(/_/g, " "); } function fmt(v) { if (v == null) return "—"; const x = Number(v); if (!Number.isFinite(x)) return "—"; return x.toFixed(2); } function relativeTime(iso) { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const diffMs = Date.now() - d.getTime(); const m = Math.round(diffMs / 60000); if (m < 1) return "just now"; if (m < 60) return `${m}m ago`; const h = Math.round(m / 60); if (h < 24) return `${h}h ago`; const days = Math.round(h / 24); return `${days}d ago`; }