File size: 14,704 Bytes
935d7f2
5c43695
935d7f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c43695
935d7f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5c43695
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935d7f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e384183
935d7f2
 
 
 
 
e384183
 
935d7f2
 
 
 
 
e384183
 
935d7f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
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 (
    <div className="profile-card">
      <div className="profile-head">
        <div className="profile-title">
          <span className="profile-eyebrow">Cognitive profile</span>
          <h2>{profile.display_name || userLabel || profile.user_id_hash}</h2>
          <code className="profile-hash" title="SHA-256 hash โ€” the only identifier persisted in the analytics layer">
            {profile.user_id_hash}
          </code>
        </div>
        <div className="profile-meta">
          <span className="profile-meta-item">
            <span className="meta-label">Bandit pulls</span>
            <span className="meta-value">{profile.total_bandit_pulls}</span>
          </span>
          <span className="profile-meta-item">
            <span className="meta-label">Tracked topics</span>
            <span className="meta-value">{profile.tracked_topics}</span>
          </span>
          <span className="profile-meta-item">
            <span className="meta-label">Last seen</span>
            <span className="meta-value">{relativeTime(profile.last_activity_ts)}</span>
          </span>
        </div>
      </div>

      {/* Per-user activity timeline */}
      {dailyActivity && dailyActivity.length > 0 && (
        <div className="trend-tile">
          <div className="trend-tile-head">
            <span>
              Activity timeline
              <InfoHint width={300}>
                This user's <strong>turn count per day</strong> 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").
              </InfoHint>
            </span>
            <span className="trend-tile-totals">
              {dailyActivity.reduce((s, d) => s + (d.count || 0), 0)} turns in window ยท{" "}
              {dailyActivity.length} active day{dailyActivity.length === 1 ? "" : "s"}
            </span>
          </div>
          <MiniLineChart
            data={dailyActivity.map((d) => ({ date: d.date, value: d.count }))}
            width={640}
            height={70}
            color="#3b82c4"
            yLabel="turns/day"
            formatValue={(v) => `${v} turn${v === 1 ? "" : "s"}`}
          />
        </div>
      )}

      {/* Three hero metrics */}
      <div className="profile-hero-row">
        <HeroTile
          label="Offer readiness"
          tier={readyTier}
          score={profile.offer_readiness_score}
          headline={profile.offer_readiness_label}
          sub="0.35ร—interest + 0.25ร—momentum + 0.20ร—stage + 0.20ร—engagement"
          info={<>
            Stage-gated composite. <code>raw = 0.35ยทinterest + 0.30ยทrecency + 0.20ยทengagement + 0.15ยทstage</code>,
            then <code>readiness = raw ร— decision_stage_score</code>. The multiplicative gate means
            a user in Awareness <em>can't</em> be Ready no matter how engaged.
            <br/><strong>Ready</strong> โ‰ฅ 0.70 ยท <strong>Likely</strong> โ‰ฅ 0.50 ยท <strong>Nurture</strong> โ‰ฅ 0.30 ยท <strong>Too early</strong> &lt; 0.30.
          </>}
        />
        <HeroTile
          label="Decision stage"
          tier={tierForStage(profile.decision_stage)}
          score={profile.decision_stage_score}
          headline={profile.decision_stage || "Unknown"}
          sub={`Recent intent mix โ€” ${formatIntentMix(profile.intent_distribution)}`}
          info={<>
            Inferred from the last 10 intents. Precedence:
            <strong> Support-needed</strong> (โ‰ฅ40% Troubleshooting),
            <strong> Action-ready</strong> (โ‰ฅ40% Decision/Recommendation),
            <strong> Evaluation</strong> (โ‰ฅ40% Comparison/Evaluation),
            <strong> Awareness</strong> (โ‰ฅ50% Definitional),
            else <strong>Exploration</strong>.
          </>}
        />
        <HeroTile
          label="Top topic"
          tier="info"
          score={profile.topic_interest_score}
          headline={profile.top_topic || "โ€”"}
          sub={`interest_score = ${fmt(profile.topic_interest_score)}`}
          info={<>
            The topic with the highest <code>interest_score</code> from
            <code> ape_user_topic_interest</code>. Score blends 40% frequency,
            25% recency, 25% engagement, 10% follow-up depth โ€” all derived
            from the user's last 30 days of activity.
          </>}
        />
      </div>

      {/* Facet grid โ€” the other 9 facets */}
      <div className="profile-facet-grid">
        <FacetTile
          label="Intent pattern"
          value={profile.dominant_intent || "โ€”"}
          footnote={`Dominant of ${Object.keys(profile.intent_distribution || {}).length} intents`}
          info={<>The intent the user fires most often. Drawn from <code>ape_turn_record.intent</code> counts. A user with mostly <em>Decision</em> is action-oriented; mostly <em>Definitional</em> means they're learning basics.</>}
        />
        <FacetTile
          label="Engagement depth"
          value={profile.engagement_depth}
          score={profile.engagement_score}
          footnote={`Max follow-ups (30d): ${profile.max_followups_30d}`}
          info={<>How deep they go on a single topic. <strong>High</strong> = max 30-day count โ‰ฅ 8. <strong>Medium</strong> โ‰ฅ 3. <strong>Low</strong> &lt; 3. A casual one-off question is Low; 12 turns on retirement is High.</>}
        />
        <FacetTile
          label="Format preference"
          value={pretty(profile.preferred_format) || "โ€”"}
          footnote={`ฮผ=${fmt(profile.preferred_format_avg_reward)} over ${profile.preferred_format_count} pulls`}
          info={<>The strategy that wins for this user โ€” picked by <code>argmax(avg_reward ร— min(count/5, 1))</code>. The count damping prevents a single high-reward pull from winning.</>}
        />
        <FacetTile
          label="Structured-thinking"
          value={profile.structured_preference}
          score={profile.structured_score}
          footnote={`Structured ฮผ=${fmt(profile.structured_avg_reward)} (n=${profile.structured_pull_count}) vs paragraph ฮผ=${fmt(profile.paragraph_avg_reward)} (n=${profile.paragraph_pull_count})`}
          info={<>Tables/bullets vs prose. We weight reward by pull count for each group; a 0.20 gap flips the label. <strong>Structured</strong> means tables/cards/steps. <strong>Paragraph</strong> means prose/analogy. <strong>Mixed</strong> = within 0.20 of each other.</>}
        />
        <FacetTile
          label="Clarity need"
          value={profile.clarity_need}
          tone={profile.clarity_need === "High" ? "warn" : "ok"}
          footnote={`${profile.clarity_signal_count} clarity signals seen`}
          info={<>How often the user signals confusion. Counts <code>format_change_request</code>, <code>reask_same_question</code>. <strong>High</strong> โ‰ฅ 5 signals โ€” consider simpler defaults for them.</>}
        />
        <FacetTile
          label="Friction signals"
          value={profile.friction_signal_count}
          tone={profile.friction_signal_count > 3 ? "warn" : "ok"}
          footnote="thumbs_down / format_change / correction"
          info={<>Hard negative signals: <code>thumbs_down</code>, <code>format_change_request</code>, <code>content_correction</code>, plus turns with negative normalized_reward. High friction users are candidates for instruction refinement on their dominant topics.</>}
        />
        <FacetTile
          label="Positive engagement"
          value={profile.positive_signal_count}
          tone={profile.positive_signal_count > 0 ? "pos" : "neutral"}
          footnote="thumbs_up / format_praise / it_worked / deeper_question"
          info={<>Hard positive signals: <code>thumbs_up</code>, <code>format_praise_explicit</code>, <code>it_worked_statement</code>, <code>deeper_question</code>, plus turns with positive normalized_reward. High count = the system is working for this user.</>}
        />
        <FacetTile
          label="Recency momentum"
          value={profile.recency_momentum}
          score={profile.recency_score}
          footnote={`${profile.turns_last_3d} turns in last 3d ยท ${profile.turns_last_30d} in last 30d`}
          info={<>Are they hot right now? Compares per-day rate in the last 3 days vs 30-day baseline. <strong>High</strong> = โ‰ฅ1.5ร— โ€” they're accelerating. <strong>Medium</strong> โ‰ฅ 0.7ร—. <strong>Low</strong> means they're going quiet.</>}
        />
        <FacetTile
          label="Learning confidence"
          value={profile.learning_confidence}
          score={profile.learning_confidence_score}
          footnote={`${profile.total_bandit_pulls} total pulls โ€” ${
            profile.learning_confidence === "High"
              ? "personalization is reliable"
              : profile.learning_confidence === "Medium"
              ? "personalization is emerging"
              : "still in cold-start โ€” explore widely"
          }`}
          info={<>How much data backs this user's personalization. <strong>High</strong> = โ‰ฅ20 total bandit pulls. <strong>Medium</strong> โ‰ฅ 8. <strong>Low</strong> = still cold-start โ€” treat preferences as tentative.</>}
        />
      </div>

      <div className="profile-footer">
        Behavioral facets only โ€” no psychological labels, no raw queries.
        All scores derive from <code>ape_turn_record</code>, <code>ape_user_bandit_state</code>,
        and <code>ape_user_topic_interest</code>.
      </div>
    </div>
  );
}

// ---------- Sub-components ----------

function HeroTile({ label, headline, sub, score, tier, info }) {
  return (
    <div className={`hero-tile hero-tile-${tier}`}>
      <div className="hero-label">
        {label}
        {info && <InfoHint width={360}>{info}</InfoHint>}
      </div>
      <div className="hero-headline">{headline}</div>
      {score != null && (
        <div className="hero-score-row">
          <div className="hero-score-bar">
            <div
              className="hero-score-fill"
              style={{ width: `${Math.max(0, Math.min(1, Number(score) || 0)) * 100}%` }}
            />
          </div>
          <span className="hero-score-num">{fmt(score)}</span>
        </div>
      )}
      {sub && <div className="hero-sub">{sub}</div>}
    </div>
  );
}

function FacetTile({ label, value, score, footnote, tone = "neutral", info }) {
  return (
    <div className={`facet-tile tone-${tone}`}>
      <div className="facet-tile-label">
        {label}
        {info && <InfoHint width={320}>{info}</InfoHint>}
      </div>
      <div className="facet-tile-value">{value ?? "โ€”"}</div>
      {score != null && (
        <div className="facet-tile-bar">
          <div
            className="facet-tile-bar-fill"
            style={{ width: `${Math.max(0, Math.min(1, Number(score) || 0)) * 100}%` }}
          />
        </div>
      )}
      {footnote && <div className="facet-tile-footnote">{footnote}</div>}
    </div>
  );
}

// ---------- 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`;
}