Spaces:
Running
Running
Add session diagnostic dashboard
Browse files- app.py +93 -0
- 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">♦</span> Signal Game</a>
|
| 202 |
<a data-page="coop"><span class="icon">⚔</span> Prisoner + Stag</a>
|
| 203 |
<a data-page="payments"><span class="icon">$</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">♦</span> Signal Game</a>
|
| 202 |
<a data-page="coop"><span class="icon">⚔</span> Prisoner + Stag</a>
|
| 203 |
<a data-page="payments"><span class="icon">$</span> Payments</a>
|
| 204 |
+
<a data-page="diagnostic"><span class="icon">⚠</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>
|