ginnyxxxxxxx commited on
Commit
e4a4c32
Β·
1 Parent(s): 75ab97e
Files changed (1) hide show
  1. app.py +259 -295
app.py CHANGED
@@ -55,7 +55,6 @@ cot_by_agent = {}
55
  if os.path.exists(COT_PATH):
56
  with open(COT_PATH, "r") as f:
57
  cot_raw = json.load(f)
58
- # Support both list and {"inference_results": [...]} formats
59
  records = cot_raw if isinstance(cot_raw, list) else cot_raw.get("inference_results", [])
60
  for result in records:
61
  cot_by_agent[int(result["agent_id"])] = result
@@ -70,7 +69,10 @@ def get_cot(agent_id):
70
  s1 = result.get("step1_response", "")
71
  s2 = result.get("step2_response", "")
72
  s3 = result.get("step3_response", "")
73
- return s1, s2, s3
 
 
 
74
 
75
 
76
  # ── Mobility text builders ────────────────────────────────────────────────────
@@ -85,11 +87,9 @@ def build_mobility_summary(agent_sp):
85
  obs_end = agent_sp["end_datetime"].max().strftime("%Y-%m-%d")
86
  days = (agent_sp["end_datetime"].max() - agent_sp["start_datetime"].min()).days
87
 
88
- # Top activity types
89
  act_counts = agent_sp["act_label"].value_counts().head(3)
90
  top_acts = ", ".join(f"{a} ({n})" for a, n in act_counts.items())
91
 
92
- # Time of day
93
  agent_sp2 = agent_sp.copy()
94
  agent_sp2["hour"] = agent_sp2["start_datetime"].dt.hour
95
  def tod(h):
@@ -144,10 +144,6 @@ def build_weekly_checkin(agent_sp, max_days=None):
144
 
145
  # ── HTML reasoning chain ──────────────────────────────────────────────────────
146
 
147
- # ── Paste this entire block into app.py, replacing the existing CHAIN_CSS, render_chain, and helper functions ──
148
-
149
- import re
150
-
151
  CHAIN_CSS = """
152
  <style>
153
  @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap');
@@ -166,7 +162,7 @@ CHAIN_CSS = """
166
  overflow: hidden;
167
  transition: opacity 0.3s, filter 0.3s;
168
  }
169
- .hct-stage.dim { opacity: 0.28; filter: grayscale(0.6); pointer-events: none; }
170
  .hct-stage.active { opacity: 1; }
171
 
172
  /* ── Stage header strip ── */
@@ -189,270 +185,270 @@ CHAIN_CSS = """
189
  font-weight: 600;
190
  letter-spacing: 0.04em;
191
  text-transform: uppercase;
 
192
  }
193
 
194
  /* Stage 1 colors */
195
  .hct-s1 { background: #f4f6fb; border: 1.5px solid #d4daf0; }
196
  .hct-s1 .hct-head { background: #eaecf7; border-bottom: 1px solid #d4daf0; }
197
- .hct-s1 .hct-num { background: #dde2f3; color: #3a4a80; }
198
  .hct-s1 .hct-title { color: #3a4a80; }
199
 
200
  /* Stage 2 colors */
201
  .hct-s2 { background: #fdf8f2; border: 1.5px solid #e8d5b8; }
202
  .hct-s2 .hct-head { background: #f7ede0; border-bottom: 1px solid #e8d5b8; }
203
- .hct-s2 .hct-num { background: #f0dcbf; color: #7a4a10; }
204
  .hct-s2 .hct-title { color: #7a4a10; }
205
 
206
  /* Stage 3 colors */
207
  .hct-s3 { background: #fff6f5; border: 2px solid #d4453a; }
208
  .hct-s3 .hct-head { background: #fce8e7; border-bottom: 1px solid #d4453a; }
209
- .hct-s3 .hct-num { background: #d4453a; color: #fff; }
210
  .hct-s3 .hct-title { color: #b0302a; }
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  /* ── Body ── */
213
  .hct-body { padding: 12px 14px; }
214
 
215
  /* ── Arrow connector ── */
216
  .hct-arrow {
217
- display: flex;
218
- align-items: center;
219
- gap: 8px;
220
- padding: 5px 18px;
221
- transition: opacity 0.3s;
222
  }
223
- .hct-arrow-line { flex: 1; height: 1px; background: #d8d4ce; }
224
  .hct-arrow-label {
225
- font-family: 'DM Mono', monospace;
226
- font-size: 9px;
227
- color: #b0a898;
228
- letter-spacing: 0.08em;
229
- text-transform: uppercase;
230
- white-space: nowrap;
231
- background: white;
232
- padding: 2px 8px;
233
- border: 1px solid #e0dbd4;
234
- border-radius: 20px;
235
  }
236
 
237
  /* ── Stage 1: Location table ── */
238
  .hct-loc-table {
239
- width: 100%;
240
- border-collapse: collapse;
241
- font-size: 11.5px;
242
- margin-bottom: 10px;
243
  }
244
  .hct-loc-table th {
245
- font-family: 'DM Mono', monospace;
246
- font-size: 9px;
247
- font-weight: 500;
248
- letter-spacing: 0.1em;
249
- text-transform: uppercase;
250
- color: #8090b0;
251
- border-bottom: 1px solid #d4daf0;
252
- padding: 3px 6px 5px;
253
- text-align: left;
254
  }
255
  .hct-loc-table th:not(:first-child) { text-align: right; }
256
  .hct-loc-table td {
257
- padding: 5px 6px;
258
- color: #2a3050;
259
- border-bottom: 1px solid #eaecf5;
260
- line-height: 1.3;
 
 
261
  }
262
- .hct-loc-table td:not(:first-child) { text-align: right; font-family: 'DM Mono', monospace; font-size: 11px; color: #5060a0; }
263
  .hct-loc-table tr:last-child td { border-bottom: none; }
264
- .hct-loc-name { font-weight: 500; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
 
 
 
265
  .hct-visit-bar-wrap { display: flex; align-items: center; gap: 6px; justify-content: flex-end; }
266
  .hct-visit-bar { height: 4px; border-radius: 2px; background: #6878c8; opacity: 0.55; }
267
 
268
  /* ── Stage 1: Temporal panel ── */
269
- .hct-temporal {
270
- display: grid;
271
- grid-template-columns: 1fr 1fr;
272
- gap: 8px;
273
- }
274
- .hct-temp-block {
275
- background: #eef0fa;
276
- border-radius: 8px;
277
- padding: 8px 10px;
278
- }
279
  .hct-temp-label {
280
- font-family: 'DM Mono', monospace;
281
- font-size: 9px;
282
- font-weight: 500;
283
- letter-spacing: 0.1em;
284
- text-transform: uppercase;
285
- color: #7080b0;
286
- margin-bottom: 6px;
287
  }
288
  .hct-seg-row { display: flex; height: 10px; border-radius: 5px; overflow: hidden; margin-bottom: 5px; }
289
- .hct-seg { display: flex; align-items: center; justify-content: center; font-size: 0; transition: width 0.5s; }
290
- .seg-morning { background: #fbbf24; }
291
- .seg-afternoon{ background: #f97316; }
292
- .seg-evening { background: #8b5cf6; }
293
- .seg-night { background: #1e3a5f; }
294
- .seg-weekday { background: #6878c8; }
295
- .seg-weekend { background: #e8c080; }
296
  .hct-legend { display: flex; flex-wrap: wrap; gap: 4px 10px; }
297
  .hct-leg-item { display: flex; align-items: center; gap: 4px; font-size: 10px; color: #5a6080; }
298
  .hct-leg-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
299
  .hct-dist-line {
300
- margin-top: 8px;
301
- font-size: 11px;
302
- color: #6070a0;
303
- font-family: 'DM Mono', monospace;
304
- padding: 5px 8px;
305
- background: #eef0fa;
306
- border-radius: 6px;
307
- display: flex;
308
- align-items: center;
309
- gap: 6px;
310
  }
311
 
312
- /* ── Stage 2: 2Γ—2 grid ── */
313
- .hct-dim-grid {
314
- display: grid;
315
- grid-template-columns: 1fr 1fr;
316
- gap: 8px;
317
- }
318
  .hct-dim-card {
319
- background: #fff;
320
- border: 1px solid #e8d5b8;
321
- border-radius: 8px;
322
- padding: 9px 11px;
323
- }
324
- .hct-dim-head {
325
- display: flex;
326
- align-items: center;
327
- gap: 6px;
328
- margin-bottom: 5px;
329
  }
 
330
  .hct-dim-icon { font-size: 13px; line-height: 1; }
331
  .hct-dim-name {
332
- font-family: 'DM Mono', monospace;
333
- font-size: 9px;
334
- font-weight: 500;
335
- letter-spacing: 0.1em;
336
- text-transform: uppercase;
337
- color: #a07040;
338
- }
339
- .hct-dim-text {
340
- font-size: 11px;
341
- color: #3a2a10;
342
- line-height: 1.55;
343
  }
 
344
  .hct-dim-empty { color: #ccc; font-style: italic; }
345
 
346
- /* ── Stage 3: prediction ── */
347
- .hct-pred-row {
348
- display: flex;
349
- align-items: flex-start;
350
- gap: 16px;
351
- margin-bottom: 10px;
352
- }
353
  .hct-pred-badge {
354
- background: #d4453a;
355
- color: white;
356
- border-radius: 8px;
357
- padding: 8px 14px;
358
- text-align: center;
359
- flex-shrink: 0;
360
  }
361
  .hct-pred-val { font-size: 18px; font-weight: 600; line-height: 1.2; white-space: nowrap; }
362
- .hct-pred-sub { font-family: 'DM Mono', monospace; font-size: 9px; opacity: 0.8; letter-spacing: 0.08em; text-transform: uppercase; margin-top: 2px; }
363
- .hct-conf-col { flex: 1; padding-top: 4px; }
364
- .hct-conf-label { font-family: 'DM Mono', monospace; font-size: 9px; color: #a04040; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 4px; }
 
 
 
 
 
 
365
  .hct-conf-track { height: 6px; background: #f0d0cf; border-radius: 3px; overflow: hidden; margin-bottom: 6px; }
366
  .hct-conf-fill { height: 100%; background: linear-gradient(90deg, #e74c3c, #8b0000); border-radius: 3px; }
367
  .hct-reasoning {
368
- font-size: 11.5px;
369
- color: #4a2020;
370
- line-height: 1.6;
371
- border-left: 3px solid #e8b0ae;
372
- padding-left: 10px;
373
  }
374
 
375
- /* ── Idle / loading states ── */
376
  .hct-idle { font-size: 12px; color: #b0bac8; padding: 6px 0; font-style: italic; }
377
- .hct-loading {
378
- font-size: 12px; padding: 6px 0;
379
- display: flex; align-items: center; gap: 8px;
 
380
  }
381
- .hct-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; animation: hct-pulse 1.2s ease-in-out infinite; }
382
  .hct-dot:nth-child(2) { animation-delay: 0.2s; }
383
  .hct-dot:nth-child(3) { animation-delay: 0.4s; }
384
  @keyframes hct-pulse {
385
  0%,100% { opacity: 0.2; transform: scale(0.8); }
386
- 50% { opacity: 1; transform: scale(1.1); }
387
  }
388
  .hct-s1 .hct-dot { background: #6878c8; }
389
  .hct-s2 .hct-dot { background: #c08040; }
390
  .hct-s3 .hct-dot { background: #d4453a; }
391
  </style>
 
 
 
 
 
 
 
 
 
392
  """
393
 
394
 
395
  def _loading(msg):
396
- return f'<div class="hct-loading"><span class="hct-dot"></span><span class="hct-dot"></span><span class="hct-dot"></span><span style="color:#8090a0;font-size:12px">{msg}</span></div>'
 
 
397
 
398
 
 
 
399
  def _parse_s1(text):
400
- """Returns (locations, tod, wk, dist)"""
401
- locations = []
402
- dur_map = {}
403
- tod = {}
404
- wk = {}
405
- dist = None
406
 
407
  for line in text.splitlines():
408
  s = line.strip()
409
- # Location inventory: "- Name: N visits, ..."
410
- m = re.match(r'-\s+(.+?):\s+(\d+)\s+visit', s, re.IGNORECASE)
 
411
  if m:
412
  locations.append((m.group(1).strip(), int(m.group(2))))
413
- # Duration: "- LocationName: Average duration of X minutes"
414
- m2 = re.match(r'-?\s*(.+?):\s+Average duration of ([\d.]+)\s+min', s, re.IGNORECASE)
 
 
 
 
415
  if m2:
416
  dur_map[m2.group(1).strip()] = float(m2.group(2))
417
- # TOD: "65% morning, 23% afternoon, 6% evening, 5% night"
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  if not tod:
419
- m3 = re.search(r'(\d+)%\s*morning.*?(\d+)%\s*afternoon.*?(\d+)%\s*evening.*?(\d+)%\s*night', s, re.IGNORECASE)
420
- if m3:
421
- tod = {'Morning': int(m3.group(1)), 'Afternoon': int(m3.group(2)),
422
- 'Evening': int(m3.group(3)), 'Night': int(m3.group(4))}
 
 
 
423
  # Weekday/weekend
424
  if not wk:
425
  m4 = re.search(r'(\d+)%\s*weekday.*?(\d+)%\s*weekend', s, re.IGNORECASE)
426
  if m4:
427
  wk = {'Weekday': int(m4.group(1)), 'Weekend': int(m4.group(2))}
 
428
  # Distance
429
  if not dist:
430
- m5 = re.search(r'average distance of approximately ([\d.]+)\s*miles', s, re.IGNORECASE)
431
  if m5:
432
  dist = float(m5.group(1))
433
 
434
- result_locs = [(n, v, dur_map.get(n)) for n, v in locations[:7]]
435
- return result_locs, tod, wk, dist
436
 
437
 
438
  def _parse_s2(text):
439
- """Returns dict: ROUTINE, ECONOMIC, SOCIAL, URBAN, STABILITY β†’ short summary string"""
440
  DIMS = {
441
- 'ROUTINE': ['ROUTINE', 'SCHEDULE'],
442
- 'ECONOMIC': ['ECONOMIC', 'SPENDING'],
443
- 'SOCIAL': ['SOCIAL', 'LIFESTYLE'],
444
- 'URBAN': ['URBAN', 'COMMUNITY'],
445
- 'STABILITY': ['STABILITY', 'REGULARITY', 'CONSISTENCY'],
446
  }
447
- sections = {}
448
- current_key = None
449
- current_lines = []
450
 
451
  for line in text.splitlines():
452
  s = line.strip()
453
- # Format A: "1. TITLE ANALYSIS:" or "2. ECONOMIC BEHAVIOR PATTERNS:"
454
  mA = re.match(r'^\d+\.\s+([A-Z][A-Z\s&]+?)(?:\s+ANALYSIS|\s+PATTERNS|\s+INDICATORS|\s+CHARACTERISTICS|\s+STABILITY)?:\s*$', s, re.IGNORECASE)
455
- # Format B: "STEP 1: ROUTINE & SCHEDULE ANALYSIS"
456
  mB = re.match(r'^STEP\s+\d+:\s+([A-Z][A-Z\s&]+?)(?:\s+ANALYSIS|\s+PATTERNS|\s+INDICATORS|\s+CHARACTERISTICS|\s+STABILITY)?\s*$', s, re.IGNORECASE)
457
  mm = mA or mB
458
  if mm:
@@ -463,8 +459,7 @@ def _parse_s2(text):
463
  elif current_key and s:
464
  if re.match(r'^\d+\.\d+', s):
465
  sub = re.sub(r'^\d+\.\d+[^:]*:\s*', '', s)
466
- if sub:
467
- current_lines.append(sub)
468
  elif s.startswith('-'):
469
  current_lines.append(s.lstrip('-').strip())
470
  elif not re.match(r'^\d+\.', s):
@@ -479,105 +474,86 @@ def _parse_s2(text):
479
  if any(kw in k for kw in keywords) and txt:
480
  sents = re.split(r'(?<=[.!?])\s+', txt.strip())
481
  summary = ' '.join(sents[:2])
482
- if len(summary) > 160:
483
- summary = summary[:157] + '…'
484
- result[dim] = summary
485
  break
486
  return result
487
 
488
 
489
  def _parse_s3(text):
490
- pred, conf, reasoning = '', 0, ''
491
- in_r = False
492
- r_lines = []
493
  for line in text.splitlines():
494
  s = line.strip()
495
  if s.startswith('INCOME_PREDICTION:'):
496
  pred = s.replace('INCOME_PREDICTION:', '').strip()
497
  elif s.startswith('INCOME_CONFIDENCE:'):
498
- try:
499
- conf = int(re.search(r'\d+', s).group())
500
- except:
501
- conf = 0
502
  elif s.startswith('INCOME_REASONING:'):
503
  in_r = True
504
  r_lines.append(s.replace('INCOME_REASONING:', '').strip())
505
  elif in_r:
506
- if re.match(r'^2\.', s) or s.startswith('INCOME_'):
507
- break
508
- if s:
509
- r_lines.append(s)
510
  reasoning = ' '.join(r_lines).strip()
511
  sents = re.split(r'(?<=[.!?])\s+', reasoning)
512
  reasoning = ' '.join(sents[:3])
513
- if len(reasoning) > 280:
514
- reasoning = reasoning[:277] + '…'
515
- return pred, conf, reasoning
 
 
 
 
 
 
 
 
 
 
 
 
516
 
517
 
 
 
518
  def _s1_body(text, active):
519
  if not active:
520
  return '<div class="hct-idle">Press β–Ά to start</div>'
521
  if not text:
522
  return _loading('Extracting features')
 
523
  locs, tod, wk, dist = _parse_s1(text)
524
 
525
- # Location table
526
  max_v = max((v for _, v, _ in locs), default=1)
527
  rows = ''
528
  for name, visits, dur in locs:
529
  bar_w = int(60 * visits / max_v)
530
  dur_str = f'{int(dur)}m' if dur else 'β€”'
531
- rows += (
532
- f'<tr>'
533
- f'<td><span class="hct-loc-name" title="{name}">{name}</span></td>'
534
- f'<td><div class="hct-visit-bar-wrap">'
535
- f'<div class="hct-visit-bar" style="width:{bar_w}px"></div>'
536
- f'{visits}</div></td>'
537
- f'<td>{dur_str}</td>'
538
- f'</tr>'
539
- )
540
- table = (
541
- f'<table class="hct-loc-table">'
542
- f'<thead><tr><th>Location</th><th>Visits</th><th>Avg Stay</th></tr></thead>'
543
- f'<tbody>{rows}</tbody>'
544
- f'</table>'
545
- ) if rows else ''
546
-
547
- # Temporal panels
548
  def seg_bar(data, seg_classes):
549
  total = sum(data.values()) or 1
550
  segs = ''.join(
551
  f'<div class="hct-seg {cls}" style="width:{int(100*v/total)}%"></div>'
552
- for (label, v), cls in zip(data.items(), seg_classes)
553
- )
554
  legend = ''.join(
555
  f'<div class="hct-leg-item"><div class="hct-leg-dot {cls}"></div>{label} {v}%</div>'
556
- for (label, v), cls in zip(data.items(), seg_classes)
557
- )
558
  return f'<div class="hct-seg-row">{segs}</div><div class="hct-legend">{legend}</div>'
559
 
560
- tod_panel = ''
561
- if tod:
562
- tod_panel = (
563
- f'<div class="hct-temp-block">'
564
- f'<div class="hct-temp-label">Time of Day</div>'
565
- f'{seg_bar(tod, ["seg-morning","seg-afternoon","seg-evening","seg-night"])}'
566
- f'</div>'
567
- )
568
- wk_panel = ''
569
- if wk:
570
- wk_panel = (
571
- f'<div class="hct-temp-block">'
572
- f'<div class="hct-temp-label">Weekday / Weekend</div>'
573
- f'{seg_bar(wk, ["seg-weekday","seg-weekend"])}'
574
- f'</div>'
575
- )
576
- temporal = f'<div class="hct-temporal">{tod_panel}{wk_panel}</div>' if (tod_panel or wk_panel) else ''
577
-
578
- dist_line = ''
579
- if dist:
580
- dist_line = f'<div class="hct-dist-line">πŸ“ Avg trip distance &nbsp;{dist} mi</div>'
581
 
582
  return table + temporal + dist_line
583
 
@@ -588,30 +564,18 @@ def _s2_body(text, active):
588
  if not text:
589
  return _loading('Analyzing behavior')
590
  dims = _parse_s2(text)
591
-
592
- DIM_META = [
593
- ('ROUTINE', 'πŸ•', 'Schedule'),
594
- ('ECONOMIC', 'πŸ’°', 'Economic'),
595
- ('SOCIAL', 'πŸ‘₯', 'Social'),
596
- ('STABILITY', 'πŸ”„', 'Stability'),
597
- ]
598
- # fallback to URBAN if STABILITY missing
599
- if 'STABILITY' not in dims and 'URBAN' in dims:
600
- dims['STABILITY'] = dims['URBAN']
601
-
602
  cards = ''
603
  for key, icon, label in DIM_META:
604
  txt = dims.get(key, '')
605
- content = f'<div class="hct-dim-text">{txt}</div>' if txt else '<div class="hct-dim-text hct-dim-empty">β€”</div>'
606
- cards += (
607
- f'<div class="hct-dim-card">'
608
- f'<div class="hct-dim-head">'
609
- f'<span class="hct-dim-icon">{icon}</span>'
610
- f'<span class="hct-dim-name">{label}</span>'
611
- f'</div>'
612
- f'{content}'
613
- f'</div>'
614
- )
615
  return f'<div class="hct-dim-grid">{cards}</div>'
616
 
617
 
@@ -621,60 +585,63 @@ def _s3_body(text, active):
621
  if not text:
622
  return _loading('Inferring demographics')
623
  pred, conf, reasoning = _parse_s3(text)
624
- conf_pct = int(conf / 5 * 100)
625
- return (
626
- f'<div class="hct-pred-row">'
627
- f'<div class="hct-pred-badge">'
628
- f'<div class="hct-pred-val">{pred or "β€”"}</div>'
629
- f'<div class="hct-pred-sub">Income</div>'
630
- f'</div>'
631
- f'<div class="hct-conf-col">'
632
- f'<div class="hct-conf-label">Confidence &nbsp;{conf}/5</div>'
633
- f'<div class="hct-conf-track"><div class="hct-conf-fill" style="width:{conf_pct}%"></div></div>'
634
- f'</div>'
635
- f'</div>'
636
- f'<div class="hct-reasoning">{reasoning}</div>'
637
- )
638
 
 
639
 
640
- def render_chain(s1_text, s2_text, s3_text, status="idle"):
 
641
  s1_on = status in ("running1", "running2", "running3", "done")
642
  s2_on = status in ("running2", "running3", "done")
643
  s3_on = status in ("running3", "done")
644
 
645
- # For "running" states the text may be empty β†’ show loading dots
646
  s1_body = _s1_body(s1_text if s1_on else '', s1_on)
647
  s2_body = _s2_body(s2_text if s2_on else '', s2_on)
648
  s3_body = _s3_body(s3_text if s3_on else '', s3_on)
649
 
650
- def stage(cls, num, title, body, on):
 
 
 
 
 
 
 
 
 
 
 
 
651
  dim_cls = 'active' if on else 'dim'
652
- return (
653
- f'<div class="hct-stage hct-{cls} {dim_cls}">'
654
- f'<div class="hct-head">'
655
- f'<span class="hct-num">{num}</span>'
656
- f'<span class="hct-title">{title}</span>'
657
- f'</div>'
658
- f'<div class="hct-body">{body}</div>'
659
- f'</div>'
660
- )
661
 
662
  def arrow(label, on):
663
  op = '1' if on else '0.2'
664
- return (
665
- f'<div class="hct-arrow" style="opacity:{op}">'
666
- f'<div class="hct-arrow-line"></div>'
667
- f'<div class="hct-arrow-label">{label}</div>'
668
- f'<div class="hct-arrow-line"></div>'
669
- f'</div>'
670
- )
671
 
672
- html = CHAIN_CSS + '<div class="hct-root">'
673
- html += stage('s1', 'Stage 01', 'Feature Extraction', s1_body, s1_on)
674
  html += arrow('behavioral abstraction', s2_on)
675
- html += stage('s2', 'Stage 02', 'Behavioral Analysis', s2_body, s2_on)
676
  html += arrow('demographic inference', s3_on)
677
- html += stage('s3', 'Stage 03', 'Demographic Inference', s3_body, s3_on)
678
  html += '</div>'
679
  return html
680
 
@@ -768,20 +735,17 @@ def on_select(agent_id):
768
 
769
 
770
  def run_step(agent_id, step):
771
- """Reveal one stage per click. step: 0->1->2->done(-1)"""
772
  agent_id = int(agent_id)
773
- s1, s2, s3 = get_cot(agent_id)
774
  next_step = step + 1
775
  if next_step == 1:
776
- html = render_chain(s1, "", "", status="running2")
777
- label = "β–Ά Stage 2: Behavioral Analysis"
778
- return html, 1, gr.update(value=label)
779
  elif next_step == 2:
780
- html = render_chain(s1, s2, "", status="running3")
781
- label = "β–Ά Stage 3: Demographic Inference"
782
- return html, 2, gr.update(value=label)
783
  else:
784
- html = render_chain(s1, s2, s3, status="done")
785
  return html, -1, gr.update(value="β†Ί Reset")
786
 
787
 
@@ -809,7 +773,6 @@ def on_select_reset(agent_id):
809
  return map_html, summary, raw_text, demo_text, chain_html, 0, gr.update(value="β–Ά Stage 1: Feature Extraction")
810
 
811
 
812
-
813
  SHOWCASE_AGENTS = sample_agents[:6]
814
 
815
 
@@ -888,6 +851,7 @@ with gr.Blocks(title="HiCoTraj Demo") as app:
888
  fn=on_agent_click, inputs=agent_hidden,
889
  outputs=[agent_cards, map_out, summary_out, raw_out, chain_out, step_state, run_btn]
890
  )
 
891
  def on_load(agent_id):
892
  map_html, summary, raw_text, _demo_text, chain_html, step, btn = on_select_reset(agent_id)
893
  return map_html, summary, raw_text, chain_html, step, btn
 
55
  if os.path.exists(COT_PATH):
56
  with open(COT_PATH, "r") as f:
57
  cot_raw = json.load(f)
 
58
  records = cot_raw if isinstance(cot_raw, list) else cot_raw.get("inference_results", [])
59
  for result in records:
60
  cot_by_agent[int(result["agent_id"])] = result
 
69
  s1 = result.get("step1_response", "")
70
  s2 = result.get("step2_response", "")
71
  s3 = result.get("step3_response", "")
72
+ p1 = result.get("step1_prompt", "")
73
+ p2 = result.get("step2_prompt", "")
74
+ p3 = result.get("step3_prompt", "")
75
+ return s1, s2, s3, p1, p2, p3
76
 
77
 
78
  # ── Mobility text builders ────────────────────────────────────────────────────
 
87
  obs_end = agent_sp["end_datetime"].max().strftime("%Y-%m-%d")
88
  days = (agent_sp["end_datetime"].max() - agent_sp["start_datetime"].min()).days
89
 
 
90
  act_counts = agent_sp["act_label"].value_counts().head(3)
91
  top_acts = ", ".join(f"{a} ({n})" for a, n in act_counts.items())
92
 
 
93
  agent_sp2 = agent_sp.copy()
94
  agent_sp2["hour"] = agent_sp2["start_datetime"].dt.hour
95
  def tod(h):
 
144
 
145
  # ── HTML reasoning chain ──────────────────────────────────────────────────────
146
 
 
 
 
 
147
  CHAIN_CSS = """
148
  <style>
149
  @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap');
 
162
  overflow: hidden;
163
  transition: opacity 0.3s, filter 0.3s;
164
  }
165
+ .hct-stage.dim { opacity: 0.28; filter: grayscale(0.6); pointer-events: none; }
166
  .hct-stage.active { opacity: 1; }
167
 
168
  /* ── Stage header strip ── */
 
185
  font-weight: 600;
186
  letter-spacing: 0.04em;
187
  text-transform: uppercase;
188
+ flex: 1;
189
  }
190
 
191
  /* Stage 1 colors */
192
  .hct-s1 { background: #f4f6fb; border: 1.5px solid #d4daf0; }
193
  .hct-s1 .hct-head { background: #eaecf7; border-bottom: 1px solid #d4daf0; }
194
+ .hct-s1 .hct-num { background: #dde2f3; color: #3a4a80; }
195
  .hct-s1 .hct-title { color: #3a4a80; }
196
 
197
  /* Stage 2 colors */
198
  .hct-s2 { background: #fdf8f2; border: 1.5px solid #e8d5b8; }
199
  .hct-s2 .hct-head { background: #f7ede0; border-bottom: 1px solid #e8d5b8; }
200
+ .hct-s2 .hct-num { background: #f0dcbf; color: #7a4a10; }
201
  .hct-s2 .hct-title { color: #7a4a10; }
202
 
203
  /* Stage 3 colors */
204
  .hct-s3 { background: #fff6f5; border: 2px solid #d4453a; }
205
  .hct-s3 .hct-head { background: #fce8e7; border-bottom: 1px solid #d4453a; }
206
+ .hct-s3 .hct-num { background: #d4453a; color: #fff; }
207
  .hct-s3 .hct-title { color: #b0302a; }
208
 
209
+ /* ── Prompt pill ── */
210
+ .hct-prompt-wrap { padding: 0 14px 8px; }
211
+ .hct-prompt-toggle {
212
+ display: inline-flex; align-items: center; gap: 5px;
213
+ font-family: 'DM Mono', monospace; font-size: 9px;
214
+ letter-spacing: 0.08em; text-transform: uppercase;
215
+ padding: 3px 9px; border-radius: 20px; cursor: pointer;
216
+ border: 1px solid currentColor; opacity: 0.45;
217
+ transition: opacity 0.2s; background: transparent;
218
+ }
219
+ .hct-prompt-toggle:hover { opacity: 0.8; }
220
+ .hct-s1 .hct-prompt-toggle { color: #3a4a80; }
221
+ .hct-s2 .hct-prompt-toggle { color: #7a4a10; }
222
+ .hct-s3 .hct-prompt-toggle { color: #b0302a; }
223
+ .hct-prompt-box {
224
+ display: none;
225
+ margin-top: 6px;
226
+ background: rgba(0,0,0,0.03);
227
+ border-radius: 6px;
228
+ padding: 8px 10px;
229
+ font-family: 'DM Mono', monospace;
230
+ font-size: 10px;
231
+ line-height: 1.65;
232
+ color: #556;
233
+ white-space: pre-wrap;
234
+ word-break: break-word;
235
+ max-height: 150px;
236
+ overflow-y: auto;
237
+ border-left: 2px solid currentColor;
238
+ opacity: 0.7;
239
+ }
240
+ .hct-prompt-box.open { display: block; }
241
+
242
  /* ── Body ── */
243
  .hct-body { padding: 12px 14px; }
244
 
245
  /* ── Arrow connector ── */
246
  .hct-arrow {
247
+ display: flex; align-items: center; gap: 8px;
248
+ padding: 5px 18px; transition: opacity 0.3s;
 
 
 
249
  }
250
+ .hct-arrow-line { flex: 1; height: 1px; background: #d8d4ce; }
251
  .hct-arrow-label {
252
+ font-family: 'DM Mono', monospace; font-size: 9px;
253
+ color: #b0a898; letter-spacing: 0.08em; text-transform: uppercase;
254
+ white-space: nowrap; background: white;
255
+ padding: 2px 8px; border: 1px solid #e0dbd4; border-radius: 20px;
 
 
 
 
 
 
256
  }
257
 
258
  /* ── Stage 1: Location table ── */
259
  .hct-loc-table {
260
+ width: 100%; border-collapse: collapse;
261
+ font-size: 11.5px; margin-bottom: 10px;
 
 
262
  }
263
  .hct-loc-table th {
264
+ font-family: 'DM Mono', monospace; font-size: 9px; font-weight: 500;
265
+ letter-spacing: 0.1em; text-transform: uppercase; color: #8090b0;
266
+ border-bottom: 1px solid #d4daf0; padding: 3px 6px 5px; text-align: left;
 
 
 
 
 
 
267
  }
268
  .hct-loc-table th:not(:first-child) { text-align: right; }
269
  .hct-loc-table td {
270
+ padding: 5px 6px; color: #2a3050;
271
+ border-bottom: 1px solid #eaecf5; line-height: 1.3;
272
+ }
273
+ .hct-loc-table td:not(:first-child) {
274
+ text-align: right; font-family: 'DM Mono', monospace;
275
+ font-size: 11px; color: #5060a0;
276
  }
 
277
  .hct-loc-table tr:last-child td { border-bottom: none; }
278
+ .hct-loc-name {
279
+ font-weight: 500; max-width: 170px; overflow: hidden;
280
+ text-overflow: ellipsis; white-space: nowrap; display: block;
281
+ }
282
  .hct-visit-bar-wrap { display: flex; align-items: center; gap: 6px; justify-content: flex-end; }
283
  .hct-visit-bar { height: 4px; border-radius: 2px; background: #6878c8; opacity: 0.55; }
284
 
285
  /* ── Stage 1: Temporal panel ── */
286
+ .hct-temporal { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
287
+ .hct-temp-block { background: #eef0fa; border-radius: 8px; padding: 8px 10px; }
 
 
 
 
 
 
 
 
288
  .hct-temp-label {
289
+ font-family: 'DM Mono', monospace; font-size: 9px; font-weight: 500;
290
+ letter-spacing: 0.1em; text-transform: uppercase; color: #7080b0; margin-bottom: 6px;
 
 
 
 
 
291
  }
292
  .hct-seg-row { display: flex; height: 10px; border-radius: 5px; overflow: hidden; margin-bottom: 5px; }
293
+ .hct-seg { transition: width 0.5s; }
294
+ .seg-morning { background: #fbbf24; }
295
+ .seg-afternoon { background: #f97316; }
296
+ .seg-evening { background: #8b5cf6; }
297
+ .seg-night { background: #1e3a5f; }
298
+ .seg-weekday { background: #6878c8; }
299
+ .seg-weekend { background: #e8c080; }
300
  .hct-legend { display: flex; flex-wrap: wrap; gap: 4px 10px; }
301
  .hct-leg-item { display: flex; align-items: center; gap: 4px; font-size: 10px; color: #5a6080; }
302
  .hct-leg-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
303
  .hct-dist-line {
304
+ margin-top: 8px; font-size: 11px; color: #6070a0;
305
+ font-family: 'DM Mono', monospace; padding: 5px 8px;
306
+ background: #eef0fa; border-radius: 6px;
307
+ display: flex; align-items: center; gap: 6px;
 
 
 
 
 
 
308
  }
309
 
310
+ /* ── Stage 2: 2x2 grid ── */
311
+ .hct-dim-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
 
 
 
 
312
  .hct-dim-card {
313
+ background: #fff; border: 1px solid #e8d5b8;
314
+ border-radius: 8px; padding: 9px 11px;
 
 
 
 
 
 
 
 
315
  }
316
+ .hct-dim-head { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; }
317
  .hct-dim-icon { font-size: 13px; line-height: 1; }
318
  .hct-dim-name {
319
+ font-family: 'DM Mono', monospace; font-size: 9px; font-weight: 500;
320
+ letter-spacing: 0.1em; text-transform: uppercase; color: #a07040;
 
 
 
 
 
 
 
 
 
321
  }
322
+ .hct-dim-text { font-size: 11px; color: #3a2a10; line-height: 1.55; }
323
  .hct-dim-empty { color: #ccc; font-style: italic; }
324
 
325
+ /* ── Stage 3 ── */
326
+ .hct-pred-row { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 10px; }
 
 
 
 
 
327
  .hct-pred-badge {
328
+ background: #d4453a; color: white; border-radius: 8px;
329
+ padding: 8px 14px; text-align: center; flex-shrink: 0;
 
 
 
 
330
  }
331
  .hct-pred-val { font-size: 18px; font-weight: 600; line-height: 1.2; white-space: nowrap; }
332
+ .hct-pred-sub {
333
+ font-family: 'DM Mono', monospace; font-size: 9px;
334
+ opacity: 0.8; letter-spacing: 0.08em; text-transform: uppercase; margin-top: 2px;
335
+ }
336
+ .hct-conf-col { flex: 1; padding-top: 4px; }
337
+ .hct-conf-label {
338
+ font-family: 'DM Mono', monospace; font-size: 9px; color: #a04040;
339
+ letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 4px;
340
+ }
341
  .hct-conf-track { height: 6px; background: #f0d0cf; border-radius: 3px; overflow: hidden; margin-bottom: 6px; }
342
  .hct-conf-fill { height: 100%; background: linear-gradient(90deg, #e74c3c, #8b0000); border-radius: 3px; }
343
  .hct-reasoning {
344
+ font-size: 11.5px; color: #4a2020; line-height: 1.6;
345
+ border-left: 3px solid #e8b0ae; padding-left: 10px;
 
 
 
346
  }
347
 
348
+ /* ── Idle / loading ── */
349
  .hct-idle { font-size: 12px; color: #b0bac8; padding: 6px 0; font-style: italic; }
350
+ .hct-loading { font-size: 12px; padding: 6px 0; display: flex; align-items: center; gap: 8px; }
351
+ .hct-dot {
352
+ width: 6px; height: 6px; border-radius: 50%; display: inline-block;
353
+ animation: hct-pulse 1.2s ease-in-out infinite;
354
  }
 
355
  .hct-dot:nth-child(2) { animation-delay: 0.2s; }
356
  .hct-dot:nth-child(3) { animation-delay: 0.4s; }
357
  @keyframes hct-pulse {
358
  0%,100% { opacity: 0.2; transform: scale(0.8); }
359
+ 50% { opacity: 1; transform: scale(1.1); }
360
  }
361
  .hct-s1 .hct-dot { background: #6878c8; }
362
  .hct-s2 .hct-dot { background: #c08040; }
363
  .hct-s3 .hct-dot { background: #d4453a; }
364
  </style>
365
+ <script>
366
+ function hctTogglePrompt(id) {
367
+ var box = document.getElementById(id);
368
+ var btn = document.getElementById(id + '-btn');
369
+ if (!box) return;
370
+ var open = box.classList.toggle('open');
371
+ btn.textContent = open ? 'β–² hide prompt' : 'β–Ό show prompt';
372
+ }
373
+ </script>
374
  """
375
 
376
 
377
  def _loading(msg):
378
+ return (f'<div class="hct-loading">'
379
+ f'<span class="hct-dot"></span><span class="hct-dot"></span><span class="hct-dot"></span>'
380
+ f'<span style="color:#8090a0;font-size:12px">{msg}</span></div>')
381
 
382
 
383
+ # ── Parsers ───────────────────────────────────────────────────────────────────
384
+
385
  def _parse_s1(text):
386
+ locations, dur_map, tod, wk, dist = [], {}, {}, {}, None
 
 
 
 
 
387
 
388
  for line in text.splitlines():
389
  s = line.strip()
390
+
391
+ # Locations: "- Name: N visits/times/time/times each"
392
+ m = re.match(r'-\s+(.+?):\s+(\d+)\s+(?:visit|time)', s, re.IGNORECASE)
393
  if m:
394
  locations.append((m.group(1).strip(), int(m.group(2))))
395
+ continue
396
+
397
+ # Duration
398
+ m2 = re.match(r'-?\s*(.+?):\s+(?:Average duration of\s*)?([\d.]+)\s+min(?:utes?)?\s+on average', s, re.IGNORECASE)
399
+ if not m2:
400
+ m2 = re.match(r'-?\s*(.+?):\s+Average duration of ([\d.]+)\s+min', s, re.IGNORECASE)
401
  if m2:
402
  dur_map[m2.group(1).strip()] = float(m2.group(2))
403
+
404
+ # TOD format A: "65% morning, 23% afternoon, 6% evening, 5% night"
405
+ if not tod:
406
+ mA = re.search(r'(\d+)%\s*morning.*?(\d+)%\s*afternoon.*?(\d+)%\s*evening.*?(\d+)%\s*night', s, re.IGNORECASE)
407
+ if mA:
408
+ tod = {'Morning': int(mA.group(1)), 'Afternoon': int(mA.group(2)),
409
+ 'Evening': int(mA.group(3)), 'Night': int(mA.group(4))}
410
+ # TOD format B: "morning: 40%, afternoon: 36%, ..."
411
+ if not tod:
412
+ mB = re.search(r'morning[:\s]+(\d+)%.*?afternoon[:\s]+(\d+)%.*?evening[:\s]+(\d+)%.*?night[:\s]+(\d+)%', s, re.IGNORECASE)
413
+ if mB:
414
+ tod = {'Morning': int(mB.group(1)), 'Afternoon': int(mB.group(2)),
415
+ 'Evening': int(mB.group(3)), 'Night': int(mB.group(4))}
416
+ # TOD format C: "Afternoon (43%), morning (27%), ..."
417
  if not tod:
418
+ parts = re.findall(r'(morning|afternoon|evening|night)\s*\(?(\d+)%\)?', s, re.IGNORECASE)
419
+ if len(parts) >= 3:
420
+ d = {k.capitalize(): int(v) for k, v in parts}
421
+ if all(k in d for k in ['Morning', 'Afternoon', 'Evening']):
422
+ d.setdefault('Night', 0)
423
+ tod = d
424
+
425
  # Weekday/weekend
426
  if not wk:
427
  m4 = re.search(r'(\d+)%\s*weekday.*?(\d+)%\s*weekend', s, re.IGNORECASE)
428
  if m4:
429
  wk = {'Weekday': int(m4.group(1)), 'Weekend': int(m4.group(2))}
430
+
431
  # Distance
432
  if not dist:
433
+ m5 = re.search(r'average distance of approximately ([\d.]+)\s*(?:km|miles?)', s, re.IGNORECASE)
434
  if m5:
435
  dist = float(m5.group(1))
436
 
437
+ return [(n, v, dur_map.get(n)) for n, v in locations[:7]], tod, wk, dist
 
438
 
439
 
440
  def _parse_s2(text):
 
441
  DIMS = {
442
+ 'ROUTINE': ['ROUTINE', 'SCHEDULE'],
443
+ 'ECONOMIC': ['ECONOMIC', 'SPENDING'],
444
+ 'SOCIAL': ['SOCIAL', 'LIFESTYLE'],
445
+ 'STABILITY': ['STABILITY', 'REGULARITY', 'CONSISTENCY', 'URBAN'],
 
446
  }
447
+ sections, current_key, current_lines = {}, None, []
 
 
448
 
449
  for line in text.splitlines():
450
  s = line.strip()
 
451
  mA = re.match(r'^\d+\.\s+([A-Z][A-Z\s&]+?)(?:\s+ANALYSIS|\s+PATTERNS|\s+INDICATORS|\s+CHARACTERISTICS|\s+STABILITY)?:\s*$', s, re.IGNORECASE)
 
452
  mB = re.match(r'^STEP\s+\d+:\s+([A-Z][A-Z\s&]+?)(?:\s+ANALYSIS|\s+PATTERNS|\s+INDICATORS|\s+CHARACTERISTICS|\s+STABILITY)?\s*$', s, re.IGNORECASE)
453
  mm = mA or mB
454
  if mm:
 
459
  elif current_key and s:
460
  if re.match(r'^\d+\.\d+', s):
461
  sub = re.sub(r'^\d+\.\d+[^:]*:\s*', '', s)
462
+ if sub: current_lines.append(sub)
 
463
  elif s.startswith('-'):
464
  current_lines.append(s.lstrip('-').strip())
465
  elif not re.match(r'^\d+\.', s):
 
474
  if any(kw in k for kw in keywords) and txt:
475
  sents = re.split(r'(?<=[.!?])\s+', txt.strip())
476
  summary = ' '.join(sents[:2])
477
+ result[dim] = summary[:157] + '…' if len(summary) > 160 else summary
 
 
478
  break
479
  return result
480
 
481
 
482
  def _parse_s3(text):
483
+ pred, conf, r_lines, in_r = '', 0, [], False
 
 
484
  for line in text.splitlines():
485
  s = line.strip()
486
  if s.startswith('INCOME_PREDICTION:'):
487
  pred = s.replace('INCOME_PREDICTION:', '').strip()
488
  elif s.startswith('INCOME_CONFIDENCE:'):
489
+ try: conf = int(re.search(r'\d+', s).group())
490
+ except: pass
 
 
491
  elif s.startswith('INCOME_REASONING:'):
492
  in_r = True
493
  r_lines.append(s.replace('INCOME_REASONING:', '').strip())
494
  elif in_r:
495
+ if re.match(r'^2\.', s) or s.startswith('INCOME_'): break
496
+ if s: r_lines.append(s)
 
 
497
  reasoning = ' '.join(r_lines).strip()
498
  sents = re.split(r'(?<=[.!?])\s+', reasoning)
499
  reasoning = ' '.join(sents[:3])
500
+ return pred, conf, (reasoning[:277] + '…' if len(reasoning) > 280 else reasoning)
501
+
502
+
503
+ def _extract_prompt_instruction(prompt_text, stage):
504
+ if not prompt_text:
505
+ return ''
506
+ key = f'STEP {stage}:'
507
+ idx = prompt_text.find(key)
508
+ if idx != -1:
509
+ return prompt_text[idx:idx + 600].strip()
510
+ # fallback: first meaningful line
511
+ for line in prompt_text.strip().splitlines():
512
+ if line.strip():
513
+ return line.strip()[:300]
514
+ return ''
515
 
516
 
517
+ # ── Body renderers ────────────────────────────────────────────────────────────
518
+
519
  def _s1_body(text, active):
520
  if not active:
521
  return '<div class="hct-idle">Press β–Ά to start</div>'
522
  if not text:
523
  return _loading('Extracting features')
524
+
525
  locs, tod, wk, dist = _parse_s1(text)
526
 
 
527
  max_v = max((v for _, v, _ in locs), default=1)
528
  rows = ''
529
  for name, visits, dur in locs:
530
  bar_w = int(60 * visits / max_v)
531
  dur_str = f'{int(dur)}m' if dur else 'β€”'
532
+ rows += (f'<tr>'
533
+ f'<td><span class="hct-loc-name" title="{name}">{name}</span></td>'
534
+ f'<td><div class="hct-visit-bar-wrap">'
535
+ f'<div class="hct-visit-bar" style="width:{bar_w}px"></div>{visits}</div></td>'
536
+ f'<td>{dur_str}</td></tr>')
537
+ table = (f'<table class="hct-loc-table">'
538
+ f'<thead><tr><th>Location</th><th>Visits</th><th>Avg Stay</th></tr></thead>'
539
+ f'<tbody>{rows}</tbody></table>') if rows else ''
540
+
 
 
 
 
 
 
 
 
541
  def seg_bar(data, seg_classes):
542
  total = sum(data.values()) or 1
543
  segs = ''.join(
544
  f'<div class="hct-seg {cls}" style="width:{int(100*v/total)}%"></div>'
545
+ for (label, v), cls in zip(data.items(), seg_classes))
 
546
  legend = ''.join(
547
  f'<div class="hct-leg-item"><div class="hct-leg-dot {cls}"></div>{label} {v}%</div>'
548
+ for (label, v), cls in zip(data.items(), seg_classes))
 
549
  return f'<div class="hct-seg-row">{segs}</div><div class="hct-legend">{legend}</div>'
550
 
551
+ tod_panel = (f'<div class="hct-temp-block"><div class="hct-temp-label">Time of Day</div>'
552
+ f'{seg_bar(tod, ["seg-morning","seg-afternoon","seg-evening","seg-night"])}</div>') if tod else ''
553
+ wk_panel = (f'<div class="hct-temp-block"><div class="hct-temp-label">Weekday / Weekend</div>'
554
+ f'{seg_bar(wk, ["seg-weekday","seg-weekend"])}</div>') if wk else ''
555
+ temporal = f'<div class="hct-temporal">{tod_panel}{wk_panel}</div>' if (tod_panel or wk_panel) else ''
556
+ dist_line = f'<div class="hct-dist-line">πŸ“ Avg trip distance &nbsp;{dist} mi</div>' if dist else ''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
558
  return table + temporal + dist_line
559
 
 
564
  if not text:
565
  return _loading('Analyzing behavior')
566
  dims = _parse_s2(text)
567
+ DIM_META = [('ROUTINE','πŸ•','Schedule'), ('ECONOMIC','πŸ’°','Economic'),
568
+ ('SOCIAL','πŸ‘₯','Social'), ('STABILITY','πŸ”„','Stability')]
 
 
 
 
 
 
 
 
 
569
  cards = ''
570
  for key, icon, label in DIM_META:
571
  txt = dims.get(key, '')
572
+ content = (f'<div class="hct-dim-text">{txt}</div>' if txt
573
+ else '<div class="hct-dim-text hct-dim-empty">β€”</div>')
574
+ cards += (f'<div class="hct-dim-card">'
575
+ f'<div class="hct-dim-head">'
576
+ f'<span class="hct-dim-icon">{icon}</span>'
577
+ f'<span class="hct-dim-name">{label}</span></div>'
578
+ f'{content}</div>')
 
 
 
579
  return f'<div class="hct-dim-grid">{cards}</div>'
580
 
581
 
 
585
  if not text:
586
  return _loading('Inferring demographics')
587
  pred, conf, reasoning = _parse_s3(text)
588
+ return (f'<div class="hct-pred-row">'
589
+ f'<div class="hct-pred-badge">'
590
+ f'<div class="hct-pred-val">{pred or "β€”"}</div>'
591
+ f'<div class="hct-pred-sub">Income</div></div>'
592
+ f'</div>'
593
+ f'<div class="hct-reasoning">{reasoning}</div>')
594
+
 
 
 
 
 
 
 
595
 
596
+ # ── Main renderer ─────────────────────────────────────────────────────────────
597
 
598
+ def render_chain(s1_text, s2_text, s3_text, status="idle",
599
+ s1_prompt="", s2_prompt="", s3_prompt=""):
600
  s1_on = status in ("running1", "running2", "running3", "done")
601
  s2_on = status in ("running2", "running3", "done")
602
  s3_on = status in ("running3", "done")
603
 
 
604
  s1_body = _s1_body(s1_text if s1_on else '', s1_on)
605
  s2_body = _s2_body(s2_text if s2_on else '', s2_on)
606
  s3_body = _s3_body(s3_text if s3_on else '', s3_on)
607
 
608
+ def prompt_pill(pid, prompt_text, stage_num):
609
+ instr = _extract_prompt_instruction(prompt_text, stage_num)
610
+ if not instr:
611
+ return ''
612
+ safe = instr.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
613
+ return (f'<div class="hct-prompt-wrap">'
614
+ f'<span id="{pid}-btn" class="hct-prompt-toggle" '
615
+ f'onclick="hctTogglePrompt(\'{pid}\')">'
616
+ f'β–Ό show prompt</span>'
617
+ f'<div id="{pid}" class="hct-prompt-box">{safe}</div>'
618
+ f'</div>')
619
+
620
+ def stage(cls, num, title, body, on, prompt_text, stage_num):
621
  dim_cls = 'active' if on else 'dim'
622
+ pill = prompt_pill(f'hct-p-{cls}', prompt_text, stage_num) if on and prompt_text else ''
623
+ return (f'<div class="hct-stage hct-{cls} {dim_cls}">'
624
+ f'<div class="hct-head">'
625
+ f'<span class="hct-num">{num}</span>'
626
+ f'<span class="hct-title">{title}</span>'
627
+ f'</div>'
628
+ f'{pill}'
629
+ f'<div class="hct-body">{body}</div>'
630
+ f'</div>')
631
 
632
  def arrow(label, on):
633
  op = '1' if on else '0.2'
634
+ return (f'<div class="hct-arrow" style="opacity:{op}">'
635
+ f'<div class="hct-arrow-line"></div>'
636
+ f'<div class="hct-arrow-label">{label}</div>'
637
+ f'<div class="hct-arrow-line"></div></div>')
 
 
 
638
 
639
+ html = CHAIN_CSS + '<div class="hct-root">'
640
+ html += stage('s1', 'Stage 01', 'Feature Extraction', s1_body, s1_on, s1_prompt, 1)
641
  html += arrow('behavioral abstraction', s2_on)
642
+ html += stage('s2', 'Stage 02', 'Behavioral Analysis', s2_body, s2_on, s2_prompt, 2)
643
  html += arrow('demographic inference', s3_on)
644
+ html += stage('s3', 'Stage 03', 'Demographic Inference', s3_body, s3_on, s3_prompt, 3)
645
  html += '</div>'
646
  return html
647
 
 
735
 
736
 
737
  def run_step(agent_id, step):
 
738
  agent_id = int(agent_id)
739
+ s1, s2, s3, p1, p2, p3 = get_cot(agent_id)
740
  next_step = step + 1
741
  if next_step == 1:
742
+ html = render_chain(s1, "", "", status="running2", s1_prompt=p1)
743
+ return html, 1, gr.update(value="β–Ά Stage 2: Behavioral Analysis")
 
744
  elif next_step == 2:
745
+ html = render_chain(s1, s2, "", status="running3", s1_prompt=p1, s2_prompt=p2)
746
+ return html, 2, gr.update(value="β–Ά Stage 3: Demographic Inference")
 
747
  else:
748
+ html = render_chain(s1, s2, s3, status="done", s1_prompt=p1, s2_prompt=p2, s3_prompt=p3)
749
  return html, -1, gr.update(value="β†Ί Reset")
750
 
751
 
 
773
  return map_html, summary, raw_text, demo_text, chain_html, 0, gr.update(value="β–Ά Stage 1: Feature Extraction")
774
 
775
 
 
776
  SHOWCASE_AGENTS = sample_agents[:6]
777
 
778
 
 
851
  fn=on_agent_click, inputs=agent_hidden,
852
  outputs=[agent_cards, map_out, summary_out, raw_out, chain_out, step_state, run_btn]
853
  )
854
+
855
  def on_load(agent_id):
856
  map_html, summary, raw_text, _demo_text, chain_html, step, btn = on_select_reset(agent_id)
857
  return map_html, summary, raw_text, chain_html, step, btn