iurbinah commited on
Commit
6407dc3
·
1 Parent(s): b1c8d8c

Add session diagnostic dashboard

Browse files
Files changed (2) hide show
  1. app.py +93 -0
  2. templates/index.html +131 -2
app.py CHANGED
@@ -578,6 +578,99 @@ def api_coop_games():
578
  })
579
 
580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  @app.route("/api/columns")
582
  def api_columns():
583
  df = _get_df()
 
578
  })
579
 
580
 
581
+ @app.route("/api/session-diagnostic")
582
+ def api_session_diagnostic():
583
+ """Detect mistyped session letters using time-bucket analysis."""
584
+ df = _get_df()
585
+
586
+ letter_col = "app_consent_consolidated.1.player.session_letter"
587
+ sid_col = "app_collect_results.1.player.participant_session_id"
588
+ time_col = "participant.time_started_utc"
589
+ code_col = "participant.code"
590
+ color_col = "participant.signal_color_choice"
591
+ group_col = "signal_game.1.group.id_in_subsession"
592
+ treat_col = "signal_game.1.group.treatment"
593
+
594
+ has_letter = df[df[letter_col].notna()].copy()
595
+ has_letter["_time"] = pd.to_datetime(has_letter[time_col])
596
+ has_letter["_bucket"] = has_letter["_time"].dt.floor("10min")
597
+
598
+ canonical = {}
599
+ for letter in sorted(has_letter[letter_col].unique()):
600
+ s = has_letter[has_letter[letter_col] == letter]
601
+ mode_bucket = s["_bucket"].mode().iloc[0]
602
+ canonical[letter] = mode_bucket + pd.Timedelta(minutes=5)
603
+
604
+ sessions = []
605
+ for letter in sorted(has_letter[letter_col].unique()):
606
+ s = has_letter[has_letter[letter_col] == letter]
607
+ completed = s[s[sid_col].notna()]
608
+ played_signal = s[s[color_col].notna()]
609
+ n_total = len(s)
610
+ n_completed = len(completed)
611
+ n_signal = len(played_signal)
612
+ sessions.append({
613
+ "letter": letter,
614
+ "canonical_bucket": canonical[letter].strftime("%Y-%m-%d %H:%M"),
615
+ "n_total": n_total,
616
+ "n_completed": n_completed,
617
+ "n_signal_played": n_signal,
618
+ "mod7": n_signal % 7,
619
+ "time_min": s["_time"].min().strftime("%Y-%m-%d %H:%M:%S"),
620
+ "time_max": s["_time"].max().strftime("%Y-%m-%d %H:%M:%S"),
621
+ })
622
+
623
+ flagged = []
624
+ letters = sorted(canonical.keys())
625
+ for _, row in has_letter.iterrows():
626
+ typed = row[letter_col]
627
+ t = row["_time"]
628
+ if pd.isna(t):
629
+ continue
630
+ dists = {l: abs((t - canonical[l]).total_seconds()) for l in letters}
631
+ nearest = min(dists, key=dists.get)
632
+ if nearest != typed:
633
+ flagged.append({
634
+ "code": row[code_col],
635
+ "typed_letter": typed,
636
+ "suggested_letter": nearest,
637
+ "timestamp": t.strftime("%Y-%m-%d %H:%M:%S"),
638
+ "dist_own_min": round(dists[typed] / 60, 1),
639
+ "dist_suggested_min": round(dists[nearest] / 60, 1),
640
+ })
641
+
642
+ played = has_letter[has_letter[group_col].notna()].copy()
643
+ split_groups = []
644
+ if len(played):
645
+ for gid, g in played.groupby(group_col):
646
+ letter_counts = g[letter_col].value_counts().to_dict()
647
+ if len(letter_counts) > 1:
648
+ treat_val = g[treat_col].iloc[0]
649
+ split_groups.append({
650
+ "group_id": int(gid),
651
+ "treatment": int(treat_val) if pd.notna(treat_val) else None,
652
+ "size": len(g),
653
+ "letters": {str(k): int(v) for k, v in letter_counts.items()},
654
+ })
655
+
656
+ bucket_detail = {}
657
+ for letter in sorted(has_letter[letter_col].unique()):
658
+ s = has_letter[has_letter[letter_col] == letter]
659
+ counts = s["_bucket"].value_counts().sort_index()
660
+ bucket_detail[letter] = [
661
+ {"bucket": b.strftime("%Y-%m-%d %H:%M"), "count": int(c)}
662
+ for b, c in counts.items()
663
+ ]
664
+
665
+ return to_json({
666
+ "sessions": sessions,
667
+ "flagged": flagged,
668
+ "split_groups": split_groups,
669
+ "bucket_detail": bucket_detail,
670
+ "canonical": {l: v.strftime("%Y-%m-%d %H:%M") for l, v in canonical.items()},
671
+ })
672
+
673
+
674
  @app.route("/api/columns")
675
  def api_columns():
676
  df = _get_df()
templates/index.html CHANGED
@@ -201,6 +201,7 @@
201
  <a data-page="signal"><span class="icon">&#9830;</span> Signal Game</a>
202
  <a data-page="coop"><span class="icon">&#9876;</span> Prisoner + Stag</a>
203
  <a data-page="payments"><span class="icon">&#36;</span> Payments</a>
 
204
  </nav>
205
  <div class="sidebar-footer">
206
  <button class="btn btn-green" id="fetchBtn" style="width:100%;margin-bottom:6px">Fetch from oTree</button>
@@ -321,6 +322,19 @@
321
  </div>
322
  </div>
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  </div>
325
 
326
  <div id="toast" class="toast"></div>
@@ -340,6 +354,7 @@ $$('.sidebar nav a').forEach(link => {
340
  if (link.dataset.page === 'stats' && !statsLoaded) loadStats();
341
  if (link.dataset.page === 'signal' && !signalLoaded) loadSignal();
342
  if (link.dataset.page === 'coop' && !coopLoaded) loadCoop();
 
343
  });
344
  });
345
 
@@ -360,7 +375,7 @@ async function fetchFromOtree() {
360
  const data = await res.json();
361
  if (data.ok) {
362
  toast(`Fetched ${data.rows} rows, ${data.cols} cols`, true);
363
- await Promise.all([loadData(), loadPayments(), loadStats(), loadSignal(), loadCoop()]);
364
  } else toast(`Fetch failed: ${data.error}`, false);
365
  } catch (e) { toast(`Fetch error: ${e.message}`, false); }
366
  btn.disabled = false; btn.textContent = 'Fetch from oTree';
@@ -371,7 +386,7 @@ async function uploadFile(file) {
371
  try {
372
  const res = await fetch('/api/upload', { method: 'POST', body: form });
373
  const data = await res.json();
374
- if (data.ok) { toast(`Uploaded ${data.rows} rows`, true); await Promise.all([loadData(), loadPayments(), loadStats()]); }
375
  else toast(`Upload failed: ${data.error}`, false);
376
  } catch (e) { toast(`Upload error: ${e.message}`, false); }
377
  }
@@ -1204,6 +1219,120 @@ async function loadCoop() {
1204
 
1205
  $('#coopRefreshBtn').addEventListener('click', loadCoop);
1206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1207
  // ===================== INIT =====================
1208
  loadData();
1209
  </script>
 
201
  <a data-page="signal"><span class="icon">&#9830;</span> Signal Game</a>
202
  <a data-page="coop"><span class="icon">&#9876;</span> Prisoner + Stag</a>
203
  <a data-page="payments"><span class="icon">&#36;</span> Payments</a>
204
+ <a data-page="diagnostic"><span class="icon">&#9888;</span> Session Diagnostic</a>
205
  </nav>
206
  <div class="sidebar-footer">
207
  <button class="btn btn-green" id="fetchBtn" style="width:100%;margin-bottom:6px">Fetch from oTree</button>
 
322
  </div>
323
  </div>
324
 
325
+ <!-- PAGE 5: Session Diagnostic -->
326
+ <div class="page" id="page-diagnostic">
327
+ <div class="toolbar">
328
+ <h2>Session Letter Diagnostic</h2>
329
+ <div class="sep"></div>
330
+ <button class="btn" id="diagRefreshBtn">Reload</button>
331
+ <span class="badge" id="diagRefreshTime">-</span>
332
+ </div>
333
+ <div class="stats-scroll" id="diagBody">
334
+ <div style="color:var(--dim);padding:40px;text-align:center">Loading...</div>
335
+ </div>
336
+ </div>
337
+
338
  </div>
339
 
340
  <div id="toast" class="toast"></div>
 
354
  if (link.dataset.page === 'stats' && !statsLoaded) loadStats();
355
  if (link.dataset.page === 'signal' && !signalLoaded) loadSignal();
356
  if (link.dataset.page === 'coop' && !coopLoaded) loadCoop();
357
+ if (link.dataset.page === 'diagnostic' && !diagLoaded) loadDiagnostic();
358
  });
359
  });
360
 
 
375
  const data = await res.json();
376
  if (data.ok) {
377
  toast(`Fetched ${data.rows} rows, ${data.cols} cols`, true);
378
+ await Promise.all([loadData(), loadPayments(), loadStats(), loadSignal(), loadCoop(), loadDiagnostic()]);
379
  } else toast(`Fetch failed: ${data.error}`, false);
380
  } catch (e) { toast(`Fetch error: ${e.message}`, false); }
381
  btn.disabled = false; btn.textContent = 'Fetch from oTree';
 
386
  try {
387
  const res = await fetch('/api/upload', { method: 'POST', body: form });
388
  const data = await res.json();
389
+ if (data.ok) { toast(`Uploaded ${data.rows} rows`, true); await Promise.all([loadData(), loadPayments(), loadStats(), loadSignal(), loadCoop(), loadDiagnostic()]); }
390
  else toast(`Upload failed: ${data.error}`, false);
391
  } catch (e) { toast(`Upload error: ${e.message}`, false); }
392
  }
 
1219
 
1220
  $('#coopRefreshBtn').addEventListener('click', loadCoop);
1221
 
1222
+ // ===================== PAGE 5: SESSION DIAGNOSTIC =====================
1223
+ let diagLoaded = false;
1224
+
1225
+ async function loadDiagnostic() {
1226
+ const res = await fetch('/api/session-diagnostic');
1227
+ const D = await res.json();
1228
+ diagLoaded = true;
1229
+
1230
+ let html = '';
1231
+
1232
+ const nFlagged = D.flagged.length;
1233
+ const nSplit = D.split_groups.length;
1234
+ const nBad = D.sessions.filter(s => s.mod7 !== 0).length;
1235
+ html += `<div class="kpi-row">
1236
+ <div class="kpi accent"><div class="kpi-val">${D.sessions.length}</div><div class="kpi-label">Session letters</div></div>
1237
+ <div class="kpi ${nBad ? 'red' : 'green'}"><div class="kpi-val">${nBad}</div><div class="kpi-label">Non-mod-7 sessions</div></div>
1238
+ <div class="kpi ${nFlagged ? 'orange' : 'green'}"><div class="kpi-val">${nFlagged}</div><div class="kpi-label">Flagged participants</div></div>
1239
+ <div class="kpi ${nSplit ? 'orange' : 'green'}"><div class="kpi-val">${nSplit}</div><div class="kpi-label">Split groups</div></div>
1240
+ </div>`;
1241
+
1242
+ html += `<div class="stats-section"><h3>Session Overview</h3>`;
1243
+ html += `<table class="session-table"><thead><tr>
1244
+ <th>Letter</th><th>Canonical Time</th><th>N Total</th><th>N Completed</th>
1245
+ <th>N Signal Played</th><th>mod 7</th><th>Time Range</th>
1246
+ </tr></thead><tbody>`;
1247
+ for (const s of D.sessions) {
1248
+ const mod7Class = s.mod7 !== 0 ? 'style="color:var(--red);font-weight:700"' : 'style="color:var(--green)"';
1249
+ html += `<tr>
1250
+ <td><strong>${s.letter}</strong></td>
1251
+ <td>${s.canonical_bucket}</td>
1252
+ <td>${s.n_total}</td>
1253
+ <td>${s.n_completed}</td>
1254
+ <td>${s.n_signal_played}</td>
1255
+ <td ${mod7Class}>${s.mod7}</td>
1256
+ <td style="font-size:11px">${s.time_min}<br>${s.time_max}</td>
1257
+ </tr>`;
1258
+ }
1259
+ html += `</tbody></table></div>`;
1260
+
1261
+ html += `<div class="stats-section"><h3>Flagged Participants (likely mistyped letter)</h3>`;
1262
+ if (D.flagged.length === 0) {
1263
+ html += `<div style="color:var(--green);padding:10px">No mistyped letters detected.</div>`;
1264
+ } else {
1265
+ html += `<table class="session-table"><thead><tr>
1266
+ <th>Code</th><th>Typed Letter</th><th>Suggested Letter</th>
1267
+ <th>Timestamp</th><th>Dist to Own (min)</th><th>Dist to Suggested (min)</th>
1268
+ </tr></thead><tbody>`;
1269
+ for (const f of D.flagged) {
1270
+ html += `<tr>
1271
+ <td style="font-family:monospace;font-size:12px">${f.code}</td>
1272
+ <td><strong style="color:var(--red)">${f.typed_letter}</strong></td>
1273
+ <td><strong style="color:var(--green)">${f.suggested_letter}</strong></td>
1274
+ <td style="font-size:12px">${f.timestamp}</td>
1275
+ <td style="color:var(--red)">${f.dist_own_min}</td>
1276
+ <td style="color:var(--green)">${f.dist_suggested_min}</td>
1277
+ </tr>`;
1278
+ }
1279
+ html += `</tbody></table>`;
1280
+ }
1281
+ html += `</div>`;
1282
+
1283
+ html += `<div class="stats-section"><h3>Split Groups (groups spanning multiple letters)</h3>`;
1284
+ if (D.split_groups.length === 0) {
1285
+ html += `<div style="color:var(--green);padding:10px">All signal game groups are within a single session letter.</div>`;
1286
+ } else {
1287
+ html += `<table class="session-table"><thead><tr>
1288
+ <th>Group ID</th><th>Treatment</th><th>Size</th><th>Letter Breakdown</th>
1289
+ </tr></thead><tbody>`;
1290
+ for (const g of D.split_groups) {
1291
+ const breakdown = Object.entries(g.letters).map(([l, c]) =>
1292
+ `<span class="treat-chip">${l}: ${c}</span>`
1293
+ ).join(' ');
1294
+ html += `<tr>
1295
+ <td><strong>g${g.group_id}</strong></td>
1296
+ <td>${g.treatment === 1 ? 'Treatment' : g.treatment === 0 ? 'Control' : '?'}</td>
1297
+ <td>${g.size}</td>
1298
+ <td><div class="treat-chips">${breakdown}</div></td>
1299
+ </tr>`;
1300
+ }
1301
+ html += `</tbody></table>`;
1302
+ }
1303
+ html += `</div>`;
1304
+
1305
+ html += `<div class="stats-section"><h3>Time Bucket Distribution (10-min windows)</h3>`;
1306
+ const allCounts = Object.values(D.bucket_detail).flat().map(b => b.count);
1307
+ const maxCount = Math.max(...allCounts, 1);
1308
+ for (const letter of Object.keys(D.bucket_detail).sort()) {
1309
+ const buckets = D.bucket_detail[letter];
1310
+ const canonicalBucket = D.canonical[letter];
1311
+ html += `<div style="margin-bottom:14px">`;
1312
+ html += `<div style="font-weight:700;font-size:13px;margin-bottom:4px">Session ${letter}
1313
+ <span style="color:var(--dim);font-weight:400;font-size:11px;margin-left:8px">canonical: ${canonicalBucket}</span></div>`;
1314
+ for (const b of buckets) {
1315
+ const pct = (b.count / maxCount * 100).toFixed(0);
1316
+ const isCanonical = canonicalBucket.startsWith(b.bucket.slice(0, 15));
1317
+ const color = isCanonical ? 'var(--accent)' : 'var(--border)';
1318
+ const label = b.bucket.slice(11);
1319
+ const date = b.bucket.slice(0, 10);
1320
+ html += `<div class="hbar" style="margin-bottom:2px">
1321
+ <div class="hbar-label" style="width:130px;min-width:130px;font-size:11px">${date} ${label}</div>
1322
+ <div class="hbar-track"><div class="hbar-fill" style="width:${pct}%;background:${color}">${b.count}</div></div>
1323
+ <div class="hbar-val" style="width:30px">${b.count}</div>
1324
+ </div>`;
1325
+ }
1326
+ html += `</div>`;
1327
+ }
1328
+ html += `</div>`;
1329
+
1330
+ $('#diagBody').innerHTML = html;
1331
+ $('#diagRefreshTime').textContent = new Date().toLocaleTimeString();
1332
+ }
1333
+
1334
+ $('#diagRefreshBtn').addEventListener('click', loadDiagnostic);
1335
+
1336
  // ===================== INIT =====================
1337
  loadData();
1338
  </script>