@machinespirits/eval 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +91 -9
  2. package/config/eval-settings.yaml +3 -3
  3. package/config/paper-manifest.json +486 -0
  4. package/config/providers.yaml +9 -6
  5. package/config/tutor-agents.yaml +2261 -0
  6. package/content/README.md +23 -0
  7. package/content/courses/479/course.md +53 -0
  8. package/content/courses/479/lecture-1.md +361 -0
  9. package/content/courses/479/lecture-2.md +360 -0
  10. package/content/courses/479/lecture-3.md +655 -0
  11. package/content/courses/479/lecture-4.md +530 -0
  12. package/content/courses/479/lecture-5.md +326 -0
  13. package/content/courses/479/lecture-6.md +346 -0
  14. package/content/courses/479/lecture-7.md +326 -0
  15. package/content/courses/479/lecture-8.md +273 -0
  16. package/content/courses/479/roadmap-slides.md +656 -0
  17. package/content/manifest.yaml +8 -0
  18. package/docs/research/build.sh +44 -20
  19. package/docs/research/figures/figure10.png +0 -0
  20. package/docs/research/figures/figure11.png +0 -0
  21. package/docs/research/figures/figure3.png +0 -0
  22. package/docs/research/figures/figure4.png +0 -0
  23. package/docs/research/figures/figure5.png +0 -0
  24. package/docs/research/figures/figure6.png +0 -0
  25. package/docs/research/figures/figure7.png +0 -0
  26. package/docs/research/figures/figure8.png +0 -0
  27. package/docs/research/figures/figure9.png +0 -0
  28. package/docs/research/header.tex +23 -2
  29. package/docs/research/paper-full.md +941 -285
  30. package/docs/research/paper-short.md +216 -585
  31. package/docs/research/references.bib +132 -0
  32. package/docs/research/slides-header.tex +188 -0
  33. package/docs/research/slides-pptx.md +363 -0
  34. package/docs/research/slides.md +531 -0
  35. package/docs/research/style-reference-pptx.py +199 -0
  36. package/package.json +6 -5
  37. package/scripts/analyze-eval-results.js +69 -17
  38. package/scripts/analyze-mechanism-traces.js +763 -0
  39. package/scripts/analyze-modulation-learning.js +498 -0
  40. package/scripts/analyze-prosthesis.js +144 -0
  41. package/scripts/analyze-run.js +264 -79
  42. package/scripts/assess-transcripts.js +853 -0
  43. package/scripts/browse-transcripts.js +854 -0
  44. package/scripts/check-parse-failures.js +73 -0
  45. package/scripts/code-dialectical-modulation.js +1320 -0
  46. package/scripts/download-data.sh +55 -0
  47. package/scripts/eval-cli.js +106 -18
  48. package/scripts/generate-paper-figures.js +663 -0
  49. package/scripts/generate-paper-figures.py +577 -76
  50. package/scripts/generate-paper-tables.js +299 -0
  51. package/scripts/qualitative-analysis-ai.js +3 -3
  52. package/scripts/render-sequence-diagram.js +694 -0
  53. package/scripts/test-latency.js +210 -0
  54. package/scripts/test-rate-limit.js +95 -0
  55. package/scripts/test-token-budget.js +332 -0
  56. package/scripts/validate-paper-manifest.js +670 -0
  57. package/services/__tests__/evalConfigLoader.test.js +2 -2
  58. package/services/__tests__/learnerRubricEvaluator.test.js +361 -0
  59. package/services/__tests__/learnerTutorInteractionEngine.test.js +326 -0
  60. package/services/evaluationRunner.js +975 -98
  61. package/services/evaluationStore.js +12 -4
  62. package/services/learnerTutorInteractionEngine.js +27 -2
  63. package/services/mockProvider.js +133 -0
  64. package/services/promptRewriter.js +1471 -5
  65. package/services/rubricEvaluator.js +55 -2
  66. package/services/transcriptFormatter.js +675 -0
  67. package/docs/EVALUATION-VARIABLES.md +0 -589
  68. package/docs/REPLICATION-PLAN.md +0 -577
  69. package/scripts/analyze-run.mjs +0 -282
  70. package/scripts/compare-runs.js +0 -44
  71. package/scripts/compare-suggestions.js +0 -80
  72. package/scripts/dig-into-run.js +0 -158
  73. package/scripts/show-failed-suggestions.js +0 -64
  74. /package/scripts/{check-run.mjs → check-run.js} +0 -0
@@ -0,0 +1,854 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Transcript Browser
5
+ *
6
+ * Interactive web app for browsing evaluation runs, scenarios, and dialogue
7
+ * transcripts with sequence diagram + transcript split-pane view.
8
+ *
9
+ * Usage:
10
+ * node scripts/browse-transcripts.js [--port 3456] [--no-open]
11
+ */
12
+
13
+ import express from 'express';
14
+ import Database from 'better-sqlite3';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import YAML from 'yaml';
18
+ import { exec } from 'child_process';
19
+
20
+ const __dirname = import.meta.dirname;
21
+ const DB_PATH = path.join(__dirname, '..', 'data', 'evaluations.db');
22
+ const LOGS_DIR = path.join(__dirname, '..', 'logs', 'tutor-dialogues');
23
+ const LEARNER_CONFIG = path.join(__dirname, '..', 'config', 'learner-agents.yaml');
24
+
25
+ const args = process.argv.slice(2);
26
+ function getOption(name) {
27
+ const idx = args.indexOf('--' + name);
28
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : null;
29
+ }
30
+
31
+ const PORT = parseInt(getOption('port') || '3456');
32
+ const shouldOpen = !args.includes('--no-open');
33
+
34
+ const db = new Database(DB_PATH, { readonly: true });
35
+ const app = express();
36
+
37
+ // ── Learner config cache ────────────────────────────────────────────────────
38
+
39
+ let learnerConfig = null;
40
+ try { learnerConfig = YAML.parse(fs.readFileSync(LEARNER_CONFIG, 'utf8')); } catch {}
41
+
42
+ function resolvelearnerModels(arch) {
43
+ if (!learnerConfig) return { ego: '?', superego: '?' };
44
+ const prof = learnerConfig.profiles?.[arch] || learnerConfig.profiles?.unified;
45
+ if (prof?.ego) {
46
+ return {
47
+ ego: (prof.ego.provider ? prof.ego.provider + '.' : '') + (prof.ego.model || ''),
48
+ superego: (prof.superego?.provider ? prof.superego.provider + '.' : '') + (prof.superego?.model || ''),
49
+ };
50
+ }
51
+ if (prof?.unified_learner) {
52
+ const m = (prof.unified_learner.provider ? prof.unified_learner.provider + '.' : '') + (prof.unified_learner.model || '');
53
+ return { ego: m, superego: m };
54
+ }
55
+ return { ego: '?', superego: '?' };
56
+ }
57
+
58
+ // ── API endpoints ───────────────────────────────────────────────────────────
59
+
60
+ app.get('/api/runs', (req, res) => {
61
+ const rows = db.prepare(`
62
+ SELECT run_id,
63
+ COUNT(*) as dialogue_count,
64
+ MIN(overall_score) as min_score,
65
+ MAX(overall_score) as max_score,
66
+ GROUP_CONCAT(DISTINCT profile_name) as profiles,
67
+ GROUP_CONCAT(DISTINCT ego_model) as ego_models,
68
+ MIN(created_at) as first_created
69
+ FROM evaluation_results
70
+ WHERE dialogue_id IS NOT NULL AND overall_score IS NOT NULL
71
+ GROUP BY run_id
72
+ ORDER BY first_created DESC
73
+ `).all();
74
+
75
+ const runs = rows.map(r => {
76
+ const profiles = (r.profiles || '').split(',');
77
+ const cells = [...new Set(profiles.map(p => p.replace(/^cell_(\d+)_.*/, '$1')))].sort((a,b) => a-b);
78
+ return {
79
+ runId: r.run_id,
80
+ date: r.run_id.replace(/^eval-/, '').replace(/-[a-f0-9]+$/, ''),
81
+ dialogueCount: r.dialogue_count,
82
+ cellRange: cells.length > 0 ? cells[0] + '-' + cells[cells.length - 1] : '',
83
+ scoreRange: [r.min_score?.toFixed(0), r.max_score?.toFixed(0)].join('-'),
84
+ egoModels: [...new Set((r.ego_models || '').split(','))].map(shortModel).join(', '),
85
+ };
86
+ });
87
+ res.json(runs);
88
+ });
89
+
90
+ app.get('/api/runs/:runId', (req, res) => {
91
+ const rows = db.prepare(`
92
+ SELECT id, dialogue_id, profile_name, scenario_id, overall_score,
93
+ ego_model, judge_model, learner_architecture, superego_model,
94
+ factor_recognition
95
+ FROM evaluation_results
96
+ WHERE run_id = ? AND dialogue_id IS NOT NULL AND overall_score IS NOT NULL
97
+ ORDER BY profile_name, scenario_id, overall_score DESC
98
+ `).all(req.params.runId);
99
+
100
+ const dialogues = rows.map(r => ({
101
+ dialogueId: r.dialogue_id,
102
+ profile: r.profile_name,
103
+ scenario: r.scenario_id,
104
+ score: r.overall_score,
105
+ egoModel: r.ego_model,
106
+ judgeModel: r.judge_model,
107
+ isRecog: !!r.factor_recognition || /recog/i.test(r.profile_name),
108
+ learnerArch: r.learner_architecture,
109
+ }));
110
+ res.json(dialogues);
111
+ });
112
+
113
+ app.get('/api/dialogue/:dialogueId', (req, res) => {
114
+ const row = db.prepare(`
115
+ SELECT id, run_id, profile_name, scenario_id, dialogue_id, overall_score,
116
+ ego_model, superego_model, judge_model, learner_architecture,
117
+ score_relevance, score_specificity, score_pedagogical, score_personalization,
118
+ score_actionability, score_tone, scores_with_reasoning,
119
+ qualitative_assessment, qualitative_model, factor_recognition
120
+ FROM evaluation_results
121
+ WHERE dialogue_id = ? AND overall_score IS NOT NULL
122
+ ORDER BY id DESC LIMIT 1
123
+ `).get(req.params.dialogueId);
124
+
125
+ if (!row) return res.status(404).json({ error: 'Dialogue not found' });
126
+
127
+ // Load trace from log file
128
+ let trace = [];
129
+ let logMeta = {};
130
+ try {
131
+ const files = fs.readdirSync(LOGS_DIR).filter(f => f.includes(req.params.dialogueId));
132
+ if (files.length > 0) {
133
+ const log = JSON.parse(fs.readFileSync(path.join(LOGS_DIR, files[0]), 'utf8'));
134
+ trace = log.consolidatedTrace || log.dialogueTrace || [];
135
+ logMeta = {
136
+ totalTurns: log.totalTurns,
137
+ learnerArchitecture: log.learnerArchitecture,
138
+ };
139
+ }
140
+ } catch {}
141
+
142
+ const learnerModels = resolvelearnerModels(row.learner_architecture || logMeta.learnerArchitecture || 'unified');
143
+
144
+ let judgeScores = {};
145
+ try { judgeScores = JSON.parse(row.scores_with_reasoning || '{}'); } catch {}
146
+ let qualitative = {};
147
+ try { qualitative = JSON.parse(row.qualitative_assessment || '{}'); } catch {}
148
+
149
+ res.json({
150
+ trace,
151
+ metadata: {
152
+ runId: row.run_id,
153
+ dialogueId: row.dialogue_id,
154
+ profile: row.profile_name,
155
+ scenario: row.scenario_id,
156
+ egoModel: row.ego_model,
157
+ superegoModel: row.superego_model,
158
+ judgeModel: row.judge_model,
159
+ learnerArch: row.learner_architecture || logMeta.learnerArchitecture || '',
160
+ learnerEgoModel: learnerModels.ego,
161
+ learnerSuperegoModel: learnerModels.superego,
162
+ totalTurns: logMeta.totalTurns || '',
163
+ isRecog: !!row.factor_recognition || /recog/i.test(row.profile_name),
164
+ },
165
+ scores: {
166
+ overall: row.overall_score,
167
+ dimensions: judgeScores,
168
+ },
169
+ qualitative,
170
+ });
171
+ });
172
+
173
+ // ── Helpers ──────────────────────────────────────────────────────────────────
174
+
175
+ function shortModel(m) {
176
+ if (!m) return '?';
177
+ return String(m).replace(/^openrouter\./, '').split('/').pop().split(':')[0];
178
+ }
179
+
180
+ // ── Serve inline HTML page ──────────────────────────────────────────────────
181
+
182
+ app.get('/', (req, res) => {
183
+ res.type('html').send(PAGE_HTML);
184
+ });
185
+
186
+ // ── Page HTML ───────────────────────────────────────────────────────────────
187
+
188
+ const PAGE_HTML = `<!DOCTYPE html>
189
+ <html lang="en">
190
+ <head>
191
+ <meta charset="UTF-8">
192
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
193
+ <title>Transcript Browser</title>
194
+ <style>
195
+ :root { --bg:#0d1117; --surface:#161b22; --border:#30363d; --text:#e6edf3; --muted:#8b949e; --accent:#58a6ff; }
196
+ * { box-sizing:border-box; margin:0; padding:0; }
197
+ body { font-family:'SF Mono','Fira Code','JetBrains Mono',monospace; background:var(--bg); color:var(--text); height:100vh; overflow:hidden; display:flex; }
198
+
199
+ /* ── Sidebar ── */
200
+ .sidebar { width:280px; flex-shrink:0; border-right:1px solid var(--border); display:flex; flex-direction:column; background:var(--surface); overflow:hidden; }
201
+ .sidebar-header { padding:14px 16px 10px; border-bottom:1px solid var(--border); flex-shrink:0; }
202
+ .sidebar-header h1 { font-size:13px; font-weight:700; letter-spacing:0.5px; margin-bottom:8px; }
203
+ .filter-row { display:flex; gap:6px; margin-bottom:6px; }
204
+ .filter-input { flex:1; background:#0d1117; border:1px solid var(--border); border-radius:4px; padding:4px 8px; color:var(--text); font-size:11px; font-family:inherit; outline:none; }
205
+ .filter-input:focus { border-color:var(--accent); }
206
+ .filter-toggles { display:flex; gap:8px; font-size:10px; color:var(--muted); }
207
+ .filter-toggles label { cursor:pointer; display:flex; align-items:center; gap:3px; }
208
+ .filter-toggles input { accent-color:var(--accent); }
209
+ .sidebar-list { flex:1; overflow-y:auto; padding:4px 0; }
210
+
211
+ .run-item { padding:6px 16px; cursor:pointer; font-size:11px; border-bottom:1px solid #1c2128; }
212
+ .run-item:hover { background:rgba(255,255,255,0.03); }
213
+ .run-item.open { background:rgba(88,166,255,0.06); }
214
+ .run-header { display:flex; justify-content:space-between; align-items:center; }
215
+ .run-date { font-weight:600; color:var(--text); }
216
+ .run-count { color:var(--muted); font-size:10px; }
217
+ .run-models { font-size:9px; color:#555; margin-top:2px; }
218
+ .run-children { padding-left:8px; display:none; }
219
+ .run-item.open > .run-children { display:block; }
220
+
221
+ .cell-group { margin:2px 0; }
222
+ .cell-label { font-size:10px; font-weight:600; color:var(--muted); padding:4px 8px 2px; cursor:pointer; }
223
+ .cell-label:hover { color:var(--text); }
224
+ .cell-dialogues { display:none; }
225
+ .cell-group.open > .cell-dialogues { display:block; }
226
+
227
+ .dlg-item { padding:3px 8px 3px 16px; cursor:pointer; font-size:10px; display:flex; justify-content:space-between; align-items:center; border-radius:3px; }
228
+ .dlg-item:hover { background:rgba(255,255,255,0.04); }
229
+ .dlg-item.active { background:rgba(88,166,255,0.12); color:#fff; }
230
+ .dlg-scenario { color:#aaa; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
231
+ .dlg-score { font-weight:700; margin-left:6px; padding:1px 5px; border-radius:8px; font-size:9px; }
232
+ .score-high { background:rgba(76,175,80,0.2); color:#66bb6a; }
233
+ .score-mid { background:rgba(255,152,0,0.2); color:#ffa726; }
234
+ .score-low { background:rgba(244,67,54,0.2); color:#ef5350; }
235
+ .recog-dot { width:6px; height:6px; border-radius:50%; background:#7c4dff; display:inline-block; margin-right:4px; flex-shrink:0; }
236
+ .base-dot { width:6px; height:6px; border-radius:50%; background:#555; display:inline-block; margin-right:4px; flex-shrink:0; }
237
+
238
+ /* ── Main content ── */
239
+ .main { flex:1; display:flex; flex-direction:column; overflow:hidden; }
240
+ .top-bar { padding:10px 20px; border-bottom:1px solid var(--border); background:var(--surface); display:flex; align-items:center; justify-content:space-between; flex-shrink:0; gap:16px; min-height:60px; }
241
+ .top-bar h2 { font-size:13px; font-weight:600; }
242
+ .meta-grid { display:grid; grid-template-columns:auto auto auto; gap:1px 14px; font-size:10px; }
243
+ .meta-label { color:var(--muted); }
244
+ .meta-value { color:var(--text); font-weight:500; }
245
+ .meta-id { font-size:9px; color:#555; margin-top:2px; }
246
+ .score-badge { padding:3px 12px; border-radius:12px; font-weight:700; font-size:14px; color:#fff; }
247
+
248
+ .legend { display:flex; gap:12px; justify-content:center; padding:6px; font-size:9px; color:var(--muted); flex-shrink:0; border-bottom:1px solid var(--border); }
249
+ .legend span { display:flex; align-items:center; gap:3px; }
250
+ .legend .sw { width:12px; height:3px; border-radius:2px; }
251
+
252
+ .split { display:flex; flex:1; overflow:hidden; }
253
+
254
+ .left-pane { width:50%; overflow:auto; border-right:1px solid var(--border); padding:12px; flex-shrink:0; }
255
+ .left-pane svg { display:block; margin:0 auto; }
256
+ svg text { font-family:'SF Mono','Fira Code',monospace; }
257
+ .arrow-group { cursor:pointer; }
258
+ .arrow-group:hover .arrow-line { stroke-width:3 !important; }
259
+ .arrow-group.active .arrow-line { stroke-width:3.5 !important; filter:drop-shadow(0 0 4px currentColor); }
260
+ .arrow-group.active text { font-weight:700 !important; }
261
+
262
+ .right-pane { width:50%; overflow-y:auto; padding:12px 16px; }
263
+
264
+ .entry { padding:10px 12px; margin:4px 0; border-radius:6px; border:1px solid transparent; cursor:pointer; transition:all 0.15s; }
265
+ .entry:hover { background:rgba(255,255,255,0.03); border-color:var(--border); }
266
+ .entry.active { background:rgba(88,166,255,0.08); border-color:#58a6ff; box-shadow:0 0 12px rgba(88,166,255,0.15); }
267
+ .entry-speaker { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; display:flex; align-items:center; gap:6px; }
268
+ .entry-model { font-weight:400; color:var(--muted); font-size:9px; }
269
+ .entry-content { font-size:12px; line-height:1.6; color:#ccc; white-space:pre-wrap; word-wrap:break-word; }
270
+
271
+ .badge { font-size:9px; padding:1px 6px; border-radius:8px; font-weight:600; }
272
+ .badge.approved { background:rgba(102,187,106,0.2); color:#66bb6a; }
273
+ .badge.revise { background:rgba(255,112,67,0.2); color:#ff7043; }
274
+
275
+ /* Judge panel */
276
+ .judge-panel { flex-shrink:0; border-top:1px solid var(--border); background:var(--surface); }
277
+ .judge-toggle { padding:10px 20px; cursor:pointer; font-size:11px; text-transform:uppercase; letter-spacing:1.5px; color:var(--muted); font-weight:600; list-style:none; user-select:none; }
278
+ .judge-toggle::-webkit-details-marker { display:none; }
279
+ .judge-toggle::before { content:'\\25B8 '; }
280
+ .judge-panel[open] .judge-toggle::before { content:'\\25BE '; }
281
+ .judge-body { padding:4px 20px 16px; max-height:45vh; overflow-y:auto; }
282
+ table.judge-table { width:100%; border-collapse:collapse; font-size:11px; }
283
+ table.judge-table tr { border-bottom:1px solid #1e1e1e; }
284
+ .jd { padding:5px 8px; font-weight:500; white-space:nowrap; color:#ccc; }
285
+ .js { padding:5px 6px; text-align:center; font-weight:700; }
286
+ .jb { padding:5px 8px; }
287
+ .jr { padding:5px 8px; color:var(--muted); font-size:10px; }
288
+ .bar-bg { background:#262626; border-radius:3px; height:5px; width:80px; }
289
+ .bar-fg { border-radius:3px; height:5px; }
290
+
291
+ .qual-item { margin-bottom:12px; }
292
+ .qual-label { font-weight:600; color:#90caf9; font-size:11px; margin-bottom:3px; }
293
+ .qual-text { color:#aaa; font-size:11px; line-height:1.6; }
294
+ .qual-ktp { margin:12px 0; padding:10px; background:#1a237e; border-radius:6px; }
295
+ .qual-tags { margin-top:8px; }
296
+ .tag { display:inline-block; padding:2px 8px; margin:2px; border-radius:10px; font-size:10px; font-weight:600; background:#263238; color:#80cbc4; }
297
+
298
+ /* Empty state */
299
+ .empty-state { display:flex; align-items:center; justify-content:center; flex:1; color:var(--muted); font-size:12px; flex-direction:column; gap:8px; }
300
+ .empty-state .hint { font-size:10px; color:#444; }
301
+
302
+ /* Loading */
303
+ .loading { color:var(--muted); font-size:11px; padding:12px 16px; }
304
+ </style>
305
+ </head>
306
+ <body>
307
+
308
+ <div class="sidebar">
309
+ <div class="sidebar-header">
310
+ <h1>Transcript Browser</h1>
311
+ <div class="filter-row">
312
+ <input class="filter-input" id="searchFilter" type="text" placeholder="Filter runs/cells..." oninput="applyFilter()">
313
+ </div>
314
+ <div class="filter-toggles">
315
+ <label><input type="radio" name="condFilter" value="all" checked onchange="applyFilter()"> All</label>
316
+ <label><input type="radio" name="condFilter" value="recog" onchange="applyFilter()"> Recog</label>
317
+ <label><input type="radio" name="condFilter" value="base" onchange="applyFilter()"> Base</label>
318
+ </div>
319
+ </div>
320
+ <div class="sidebar-list" id="sidebarList">
321
+ <div class="loading">Loading runs...</div>
322
+ </div>
323
+ </div>
324
+
325
+ <div class="main">
326
+ <div class="top-bar" id="topBar">
327
+ <div class="empty-state">
328
+ <div>Select a dialogue from the sidebar</div>
329
+ <div class="hint">j/k or arrow keys to navigate steps</div>
330
+ </div>
331
+ </div>
332
+
333
+ <div class="legend" id="legendBar" style="display:none">
334
+ <span><span class="sw" style="background:#78909c"></span> Front stage</span>
335
+ <span><span class="sw" style="background:#ef5350"></span> L.Superego</span>
336
+ <span><span class="sw" style="background:#ab47bc"></span> L.Ego</span>
337
+ <span><span class="sw" style="background:#42a5f5"></span> T.Ego</span>
338
+ <span><span class="sw" style="background:#66bb6a"></span> T.Superego</span>
339
+ </div>
340
+
341
+ <div class="split" id="splitPane" style="display:none">
342
+ <div class="left-pane" id="leftPane"></div>
343
+ <div class="right-pane" id="rightPane"></div>
344
+ </div>
345
+
346
+ <div id="judgePanel"></div>
347
+ </div>
348
+
349
+ <script>
350
+ // ── State ────────────────────────────────────────────────────────────────────
351
+ let allRuns = [];
352
+ let runDialogues = {}; // runId → dialogue list
353
+ let activeDialogueId = null;
354
+ let activeStep = -1;
355
+ let totalSteps = 0;
356
+
357
+ // ── Helpers ──────────────────────────────────────────────────────────────────
358
+ function escapeHtml(t) {
359
+ if (!t) return '';
360
+ return String(t).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
361
+ }
362
+ function shortModel(m) {
363
+ if (!m) return '?';
364
+ return String(m).replace(/^openrouter\\./, '').split('/').pop().split(':')[0];
365
+ }
366
+ function scoreClass(s) { return s >= 85 ? 'score-high' : s >= 65 ? 'score-mid' : 'score-low'; }
367
+ function scoreBg(s) { return s >= 90 ? '#1b5e20' : s >= 70 ? '#e65100' : '#b71c1c'; }
368
+
369
+ // ── Sidebar: load runs ──────────────────────────────────────────────────────
370
+ async function loadRuns() {
371
+ const res = await fetch('/api/runs');
372
+ allRuns = await res.json();
373
+ renderSidebar();
374
+ }
375
+
376
+ function renderSidebar() {
377
+ const list = document.getElementById('sidebarList');
378
+ const search = (document.getElementById('searchFilter').value || '').toLowerCase();
379
+ const cond = document.querySelector('input[name="condFilter"]:checked')?.value || 'all';
380
+
381
+ let html = '';
382
+ for (const run of allRuns) {
383
+ const label = run.date + ' N=' + run.dialogueCount;
384
+ if (search && !label.toLowerCase().includes(search) && !run.runId.toLowerCase().includes(search) && !run.egoModels.toLowerCase().includes(search)) {
385
+ // Check if any loaded dialogues match
386
+ const dlgs = runDialogues[run.runId];
387
+ if (!dlgs || !dlgs.some(d => matchesFilter(d, search, cond))) continue;
388
+ }
389
+ const isOpen = runDialogues[run.runId] !== undefined;
390
+ html += '<div class="run-item' + (isOpen ? ' open' : '') + '" data-run="' + escapeHtml(run.runId) + '">';
391
+ html += '<div class="run-header" onclick="toggleRun(\\'' + escapeHtml(run.runId) + '\\')">';
392
+ html += '<span class="run-date">' + escapeHtml(run.date) + '</span>';
393
+ html += '<span class="run-count">N=' + run.dialogueCount + ' cells ' + escapeHtml(run.cellRange) + '</span>';
394
+ html += '</div>';
395
+ html += '<div class="run-models">' + escapeHtml(run.egoModels) + ' (' + escapeHtml(run.scoreRange) + ')</div>';
396
+ html += '<div class="run-children" id="run-' + escapeHtml(run.runId) + '">';
397
+ if (isOpen) html += renderRunChildren(run.runId, search, cond);
398
+ html += '</div></div>';
399
+ }
400
+ if (!html) html = '<div class="loading">No matching runs</div>';
401
+ list.innerHTML = html;
402
+ }
403
+
404
+ function matchesFilter(d, search, cond) {
405
+ if (cond === 'recog' && !d.isRecog) return false;
406
+ if (cond === 'base' && d.isRecog) return false;
407
+ if (search && !d.profile.toLowerCase().includes(search) && !d.scenario.toLowerCase().includes(search)) return false;
408
+ return true;
409
+ }
410
+
411
+ function renderRunChildren(runId, search, cond) {
412
+ const dlgs = runDialogues[runId];
413
+ if (!dlgs) return '';
414
+
415
+ // Group by profile
416
+ const byProfile = {};
417
+ for (const d of dlgs) {
418
+ if (!matchesFilter(d, search, cond)) continue;
419
+ (byProfile[d.profile] = byProfile[d.profile] || []).push(d);
420
+ }
421
+
422
+ let html = '';
423
+ for (const [profile, items] of Object.entries(byProfile)) {
424
+ const isRecog = items[0]?.isRecog;
425
+ const cellNum = profile.replace(/^cell_(\\d+)_.*/, '$1');
426
+ html += '<div class="cell-group open">';
427
+ html += '<div class="cell-label" onclick="this.parentElement.classList.toggle(\\'open\\')">';
428
+ html += '<span class="' + (isRecog ? 'recog-dot' : 'base-dot') + '"></span>';
429
+ html += escapeHtml(profile) + ' (' + items.length + ')';
430
+ html += '</div>';
431
+ html += '<div class="cell-dialogues">';
432
+ for (const d of items) {
433
+ const sc = d.score?.toFixed(0) || '--';
434
+ const scn = d.scenario.replace(/_/g, ' ').substring(0, 25);
435
+ const isActive = d.dialogueId === activeDialogueId;
436
+ html += '<div class="dlg-item' + (isActive ? ' active' : '') + '" onclick="loadDialogue(\\'' + escapeHtml(d.dialogueId) + '\\')">';
437
+ html += '<span class="dlg-scenario">' + escapeHtml(scn) + '</span>';
438
+ html += '<span class="dlg-score ' + scoreClass(d.score) + '">' + sc + '</span>';
439
+ html += '</div>';
440
+ }
441
+ html += '</div></div>';
442
+ }
443
+ return html;
444
+ }
445
+
446
+ async function toggleRun(runId) {
447
+ if (runDialogues[runId]) {
448
+ delete runDialogues[runId];
449
+ renderSidebar();
450
+ return;
451
+ }
452
+ const el = document.getElementById('run-' + runId);
453
+ if (el) el.innerHTML = '<div class="loading">Loading...</div>';
454
+
455
+ const res = await fetch('/api/runs/' + encodeURIComponent(runId));
456
+ runDialogues[runId] = await res.json();
457
+ renderSidebar();
458
+ }
459
+
460
+ function applyFilter() { renderSidebar(); }
461
+
462
+ // ── Load and render a dialogue ──────────────────────────────────────────────
463
+ async function loadDialogue(dialogueId) {
464
+ activeDialogueId = dialogueId;
465
+ activeStep = -1;
466
+
467
+ // Update sidebar active state
468
+ document.querySelectorAll('.dlg-item').forEach(el => el.classList.remove('active'));
469
+ const clicked = document.querySelector('.dlg-item[onclick*="' + dialogueId + '"]');
470
+ if (clicked) clicked.classList.add('active');
471
+
472
+ const res = await fetch('/api/dialogue/' + encodeURIComponent(dialogueId));
473
+ const data = await res.json();
474
+
475
+ const steps = traceToSteps(data.trace);
476
+ totalSteps = steps.length;
477
+
478
+ renderTopBar(data);
479
+ renderDiagram(steps, data.metadata);
480
+ renderTranscript(steps);
481
+ renderJudgePanel(data.scores, data.qualitative);
482
+
483
+ document.getElementById('legendBar').style.display = 'flex';
484
+ document.getElementById('splitPane').style.display = 'flex';
485
+ }
486
+
487
+ // ── Top bar ─────────────────────────────────────────────────────────────────
488
+ function renderTopBar(data) {
489
+ const m = data.metadata;
490
+ const score = data.scores.overall?.toFixed(1) || '--';
491
+ const condLabel = m.isRecog ? 'Recognition' : 'Base';
492
+ const scenario = (m.scenario || '').replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
493
+
494
+ document.getElementById('topBar').innerHTML =
495
+ '<div>' +
496
+ '<h2>' + escapeHtml(scenario) + '</h2>' +
497
+ '<div class="meta-grid">' +
498
+ '<span class="meta-label">Cell</span><span class="meta-value">' + escapeHtml(m.profile) + '</span><span class="meta-value">' + condLabel + (m.totalTurns ? ' · ' + m.totalTurns + ' turns' : '') + '</span>' +
499
+ '<span class="meta-label">Tutor</span><span class="meta-value">ego ' + escapeHtml(shortModel(m.egoModel)) + '</span><span class="meta-value">superego ' + escapeHtml(shortModel(m.superegoModel) || shortModel(m.egoModel)) + '</span>' +
500
+ '<span class="meta-label">Learner</span><span class="meta-value">ego ' + escapeHtml(shortModel(m.learnerEgoModel)) + '</span><span class="meta-value">superego ' + escapeHtml(shortModel(m.learnerSuperegoModel)) + '</span>' +
501
+ '<span class="meta-label">Judge</span><span class="meta-value">' + escapeHtml(shortModel(m.judgeModel)) + '</span><span></span>' +
502
+ '</div>' +
503
+ '<div class="meta-id">' + escapeHtml(m.runId) + ' · ' + escapeHtml(m.dialogueId) + '</div>' +
504
+ '</div>' +
505
+ '<span class="score-badge" style="background:' + scoreBg(parseFloat(score)) + '">' + score + '</span>';
506
+ }
507
+
508
+ // ── Trace → Steps (ported from render-sequence-diagram.js) ──────────────────
509
+ function extractLearnerQuery(entry) {
510
+ const raw = entry.rawContext || '';
511
+ const match = raw.match(/Learner Messages?:\\s*(.+?)(?:\\n<\\/|$)/s)
512
+ || raw.match(/Recent Chat History\\n-\\s*User:\\s*"(.+?)"/s);
513
+ return match ? match[1].trim() : null;
514
+ }
515
+
516
+ function fullContent(entry) {
517
+ if (entry.agent === 'superego' && entry.action === 'review') {
518
+ return entry.feedback || entry.verdict?.feedback || '';
519
+ }
520
+ if (entry.suggestions?.length > 0) {
521
+ return entry.suggestions.map(s => s.message || s.text || s.title || '').join('\\n\\n');
522
+ }
523
+ if (entry.agent === 'user' && entry.action === 'context_input') {
524
+ return extractLearnerQuery(entry) || '(scenario context)';
525
+ }
526
+ if (entry.agent === 'user' && entry.action === 'turn_action') {
527
+ return entry.contextSummary || entry.detail || '';
528
+ }
529
+ return entry.detail || entry.contextSummary || '';
530
+ }
531
+
532
+ function snippet(entry, maxLen) {
533
+ return fullContent(entry).substring(0, maxLen || 90);
534
+ }
535
+
536
+ function traceToSteps(trace) {
537
+ const steps = [];
538
+ let dialogueTurn = 0;
539
+
540
+ const learnerBlockStarts = new Set();
541
+ trace.forEach((e, i) => { if (e.agent === 'learner_ego_initial') learnerBlockStarts.add(i); });
542
+
543
+ let needsResponseArrow = false;
544
+
545
+ for (let i = 0; i < trace.length; i++) {
546
+ const e = trace[i];
547
+ const { agent, action } = e;
548
+
549
+ if (learnerBlockStarts.has(i) && needsResponseArrow) {
550
+ let responseContent = '';
551
+ for (let j = i - 1; j >= 0; j--) {
552
+ const prev = trace[j];
553
+ if (prev.agent === 'ego' && (prev.action === 'generate' || prev.action === 'revise' || prev.action === 'incorporate-feedback')) {
554
+ responseContent = fullContent(prev);
555
+ break;
556
+ }
557
+ }
558
+ steps.push({ from: 'tutor_ego', to: 'learner_ego', label: 'Response', detail: '', fullDetail: responseContent, type: 'response', speaker: 'TUTOR EGO' });
559
+ needsResponseArrow = false;
560
+ }
561
+
562
+ if (agent === 'system') continue;
563
+ if (agent === 'user' && action === 'final_output') continue;
564
+ if (agent === 'learner_synthesis') continue;
565
+
566
+ if (agent === 'user' && action === 'context_input') {
567
+ dialogueTurn++;
568
+ if (dialogueTurn === 1) {
569
+ const query = extractLearnerQuery(e);
570
+ const full = query || '(scenario prompt)';
571
+ steps.push({ from: 'learner_ego', to: 'tutor_ego', label: 'Initial query', detail: full.substring(0, 120), fullDetail: full, type: 'front', speaker: 'LEARNER' });
572
+ }
573
+ needsResponseArrow = true;
574
+ continue;
575
+ }
576
+
577
+ if (agent === 'ego' && (action === 'generate' || action === 'revise' || action === 'incorporate-feedback')) {
578
+ const full = fullContent(e);
579
+ let superegoFollows = false;
580
+ for (let j = i + 1; j < trace.length; j++) {
581
+ if (trace[j].agent === 'superego' && trace[j].action === 'review') { superegoFollows = true; break; }
582
+ if (learnerBlockStarts.has(j)) break;
583
+ if (trace[j].agent === 'user' && trace[j].action === 'context_input') break;
584
+ }
585
+
586
+ if (action !== 'generate' && !superegoFollows) {
587
+ steps.push({ from: 'tutor_ego', to: 'learner_ego', label: 'Response', detail: '', fullDetail: full, type: 'response', latency: e.metrics?.latencyMs || null, speaker: 'TUTOR EGO', model: e.metrics?.model || null });
588
+ needsResponseArrow = false;
589
+ } else {
590
+ const label = action === 'generate' ? 'Draft' : 'Revised';
591
+ steps.push({ from: 'tutor_ego', to: 'tutor_superego', label, detail: snippet(e, 120), fullDetail: full, type: 'back', latency: e.metrics?.latencyMs || null, speaker: action === 'generate' ? 'TUTOR EGO (draft)' : 'TUTOR EGO (revised)', model: e.metrics?.model || null });
592
+ }
593
+ continue;
594
+ }
595
+
596
+ if (agent === 'superego' && action === 'review') {
597
+ const approved = e.approved;
598
+ const full = fullContent(e);
599
+ if (approved) {
600
+ steps.push({ from: 'tutor_superego', to: 'tutor_ego', label: 'Approved \\u2713', detail: snippet(e, 120), fullDetail: full, type: 'back', approved: true, latency: e.metrics?.latencyMs || null, speaker: 'SUPEREGO', model: e.metrics?.model || null });
601
+ let responseContent = '';
602
+ for (let j = i - 1; j >= 0; j--) {
603
+ const prev = trace[j];
604
+ if (prev.agent === 'ego' && (prev.action === 'generate' || prev.action === 'revise' || prev.action === 'incorporate-feedback')) {
605
+ responseContent = fullContent(prev);
606
+ break;
607
+ }
608
+ }
609
+ steps.push({ from: 'tutor_ego', to: 'learner_ego', label: 'Response', detail: '', fullDetail: responseContent, type: 'response', speaker: 'TUTOR EGO' });
610
+ needsResponseArrow = false;
611
+ } else {
612
+ steps.push({ from: 'tutor_superego', to: 'tutor_ego', label: 'Revise \\u21BB', detail: snippet(e, 120), fullDetail: full, type: 'back', approved: false, latency: e.metrics?.latencyMs || null, speaker: 'SUPEREGO', model: e.metrics?.model || null });
613
+ }
614
+ continue;
615
+ }
616
+
617
+ if (agent === 'learner_ego_initial' && action === 'deliberation') {
618
+ const full = fullContent(e);
619
+ steps.push({ from: 'learner_ego', to: 'learner_superego', label: 'Reaction', detail: snippet(e, 120), fullDetail: full, type: 'back', speaker: 'LEARNER EGO' });
620
+ continue;
621
+ }
622
+
623
+ if (agent === 'learner_superego' && action === 'deliberation') {
624
+ const full = fullContent(e);
625
+ steps.push({ from: 'learner_superego', to: 'learner_ego', label: 'Critique', detail: snippet(e, 120), fullDetail: full, type: 'back', speaker: 'LEARNER SUPEREGO' });
626
+ continue;
627
+ }
628
+
629
+ if (agent === 'learner_ego_revision') continue;
630
+
631
+ if (agent === 'user' && action === 'turn_action') {
632
+ const full = fullContent(e);
633
+ steps.push({ from: 'learner_ego', to: 'tutor_ego', label: 'Turn ' + (dialogueTurn + 1), detail: snippet(e, 120), fullDetail: full, type: 'front', speaker: 'LEARNER' });
634
+ needsResponseArrow = true;
635
+ continue;
636
+ }
637
+ }
638
+ return steps;
639
+ }
640
+
641
+ // ── SVG diagram ─────────────────────────────────────────────────────────────
642
+ function renderDiagram(steps, meta) {
643
+ const actors = [
644
+ { id: 'learner_superego', label: 'L.Superego', model: shortModel(meta.learnerSuperegoModel), color: '#fce4ec', textColor: '#c62828', stroke: '#ef5350' },
645
+ { id: 'learner_ego', label: 'L.Ego', model: shortModel(meta.learnerEgoModel), color: '#f3e5f5', textColor: '#6a1b9a', stroke: '#ab47bc' },
646
+ { id: 'tutor_ego', label: 'T.Ego', model: shortModel(meta.egoModel), color: '#e3f2fd', textColor: '#1565c0', stroke: '#42a5f5' },
647
+ { id: 'tutor_superego', label: 'T.Superego', model: shortModel(meta.superegoModel), color: '#e8f5e9', textColor: '#2e7d32', stroke: '#66bb6a' },
648
+ ];
649
+ const colMap = {};
650
+ actors.forEach((a, i) => { colMap[a.id] = i; });
651
+
652
+ const colWidth = 140;
653
+ const rowHeight = 38;
654
+ const headerHeight = 56;
655
+ const padding = 20;
656
+ const svgWidth = colWidth * actors.length + padding * 2;
657
+ const svgHeight = headerHeight + steps.length * rowHeight + 30;
658
+
659
+ let svg = '';
660
+
661
+ // Actor headers
662
+ actors.forEach((a, i) => {
663
+ const x = padding + i * colWidth;
664
+ const cx = x + colWidth / 2;
665
+ svg += '<rect x="' + (x+8) + '" y="4" width="' + (colWidth-16) + '" height="40" rx="5" fill="' + a.color + '" stroke="' + a.stroke + '" stroke-width="1"/>';
666
+ svg += '<text x="' + cx + '" y="21" text-anchor="middle" font-size="11" font-weight="600" fill="' + a.textColor + '">' + a.label + '</text>';
667
+ svg += '<text x="' + cx + '" y="36" text-anchor="middle" font-size="8.5" fill="' + a.textColor + '" opacity="0.65">' + a.model + '</text>';
668
+ svg += '<line x1="' + cx + '" y1="' + headerHeight + '" x2="' + cx + '" y2="' + (svgHeight-10) + '" stroke="#333" stroke-width="1" stroke-dasharray="3,3"/>';
669
+ });
670
+
671
+ // Turn separators
672
+ let prevTurn = '';
673
+ steps.forEach((s, i) => {
674
+ if (s.label.startsWith('Turn ') || s.label === 'Initial query') {
675
+ const num = s.label === 'Initial query' ? 1 : parseInt(s.label.replace('Turn ', ''));
676
+ if (num !== prevTurn) {
677
+ const y = headerHeight + i * rowHeight;
678
+ svg += '<line x1="' + padding + '" y1="' + y + '" x2="' + (svgWidth-padding) + '" y2="' + y + '" stroke="#444" stroke-width="0.5"/>';
679
+ svg += '<text x="' + (svgWidth-padding+3) + '" y="' + (y+12) + '" font-size="9" fill="#666" font-weight="600">T' + num + '</text>';
680
+ prevTurn = num;
681
+ }
682
+ }
683
+ });
684
+
685
+ // Arrows
686
+ steps.forEach((step, i) => {
687
+ const fromCol = colMap[step.from];
688
+ const toCol = colMap[step.to];
689
+ if (fromCol === undefined || toCol === undefined) return;
690
+
691
+ const fromX = padding + fromCol * colWidth + colWidth / 2;
692
+ const toX = padding + toCol * colWidth + colWidth / 2;
693
+ const y = headerHeight + i * rowHeight + rowHeight / 2;
694
+ const isLR = fromX < toX;
695
+
696
+ let color;
697
+ if (step.type === 'front' || step.type === 'response') { color = '#78909c'; }
698
+ else { color = actors[fromCol].stroke; }
699
+
700
+ const sw = (step.type === 'front' || step.type === 'response') ? 2.2 : 1.2;
701
+ const tipOff = isLR ? -6 : 6;
702
+
703
+ svg += '<g data-step="' + i + '" class="arrow-group" onclick="highlight(' + i + ')">';
704
+ svg += '<line x1="' + fromX + '" y1="' + y + '" x2="' + toX + '" y2="' + y + '" stroke="transparent" stroke-width="20"/>';
705
+ svg += '<line x1="' + fromX + '" y1="' + y + '" x2="' + (toX+tipOff) + '" y2="' + y + '" stroke="' + color + '" stroke-width="' + sw + '" class="arrow-line"/>';
706
+
707
+ if (isLR) {
708
+ svg += '<polygon points="' + (toX-6) + ',' + (y-3.5) + ' ' + toX + ',' + y + ' ' + (toX-6) + ',' + (y+3.5) + '" fill="' + color + '"/>';
709
+ } else {
710
+ svg += '<polygon points="' + (toX+6) + ',' + (y-3.5) + ' ' + toX + ',' + y + ' ' + (toX+6) + ',' + (y+3.5) + '" fill="' + color + '"/>';
711
+ }
712
+
713
+ const labelX = (fromX + toX) / 2;
714
+ let labelColor = '#bbb';
715
+ if (step.approved === true) labelColor = '#66bb6a';
716
+ if (step.approved === false) labelColor = '#ff7043';
717
+
718
+ svg += '<text x="' + labelX + '" y="' + (y-6) + '" text-anchor="middle" font-size="9.5" font-weight="500" fill="' + labelColor + '">' + escapeHtml(step.label) + '</text>';
719
+ if (step.latency) {
720
+ const lat = step.latency < 1000 ? step.latency + 'ms' : (step.latency / 1000).toFixed(1) + 's';
721
+ svg += '<text x="' + labelX + '" y="' + (y+13) + '" text-anchor="middle" font-size="8" fill="#555">' + lat + '</text>';
722
+ }
723
+ svg += '</g>';
724
+ });
725
+
726
+ document.getElementById('leftPane').innerHTML =
727
+ '<svg width="' + (svgWidth+20) + '" height="' + svgHeight + '" xmlns="http://www.w3.org/2000/svg">' + svg + '</svg>';
728
+ }
729
+
730
+ // ── Transcript ──────────────────────────────────────────────────────────────
731
+ const speakerColors = {
732
+ 'TUTOR EGO': '#42a5f5', 'TUTOR EGO (draft)': '#42a5f5', 'TUTOR EGO (revised)': '#42a5f5',
733
+ 'SUPEREGO': '#66bb6a', 'LEARNER EGO': '#ab47bc', 'LEARNER SUPEREGO': '#ef5350',
734
+ 'LEARNER': '#78909c',
735
+ };
736
+
737
+ function renderTranscript(steps) {
738
+ let html = '';
739
+ steps.forEach((step, i) => {
740
+ const speaker = step.speaker || step.label;
741
+ const color = speakerColors[speaker] || '#999';
742
+ const content = step.fullDetail || step.detail || '';
743
+ if (!content && step.type === 'response') return;
744
+
745
+ let badge = '';
746
+ if (step.approved === true) badge = '<span class="badge approved">APPROVED</span>';
747
+ else if (step.approved === false) badge = '<span class="badge revise">REVISE</span>';
748
+
749
+ const modelStr = step.model ? '<span class="entry-model">' + escapeHtml(String(step.model).split('/').pop().split(':')[0]) + '</span>' : '';
750
+
751
+ html += '<div class="entry" id="entry-' + i + '" data-step="' + i + '" onclick="highlight(' + i + ')">' +
752
+ '<div class="entry-speaker" style="color:' + color + '">' + escapeHtml(speaker) + ' ' + badge + ' ' + modelStr + '</div>' +
753
+ '<div class="entry-content">' + escapeHtml(content) + '</div>' +
754
+ '</div>';
755
+ });
756
+ document.getElementById('rightPane').innerHTML = html;
757
+ }
758
+
759
+ // ── Judge panel ─────────────────────────────────────────────────────────────
760
+ function renderJudgePanel(scores, qualitative) {
761
+ if (!scores?.dimensions && !qualitative) {
762
+ document.getElementById('judgePanel').innerHTML = '';
763
+ return;
764
+ }
765
+
766
+ let judgeRows = '';
767
+ if (scores?.dimensions) {
768
+ for (const [dim, data] of Object.entries(scores.dimensions)) {
769
+ const label = dim.replace(/_/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
770
+ const sv = data.score || 0;
771
+ const reasoning = escapeHtml(data.reasoning || '');
772
+ const barW = (sv / 5) * 100;
773
+ const barC = sv >= 4 ? '#4caf50' : sv >= 3 ? '#ff9800' : '#f44336';
774
+ judgeRows += '<tr><td class="jd">' + label + '</td><td class="js" style="color:' + barC + '">' + sv + '</td>' +
775
+ '<td class="jb"><div class="bar-bg"><div class="bar-fg" style="width:' + barW + '%;background:' + barC + '"></div></div></td>' +
776
+ '<td class="jr">' + reasoning + '</td></tr>';
777
+ }
778
+ }
779
+
780
+ let qualHtml = '';
781
+ const axes = [
782
+ ['pedagogical_arc', 'Pedagogical Arc'], ['recognition_dynamics', 'Recognition Dynamics'],
783
+ ['superego_effectiveness', 'Superego Effectiveness'], ['learner_trajectory', 'Learner Trajectory'],
784
+ ['missed_opportunities', 'Missed Opportunities'], ['overall_narrative', 'Overall Narrative'],
785
+ ];
786
+ for (const [k, lab] of axes) {
787
+ if (qualitative && qualitative[k]) {
788
+ qualHtml += '<div class="qual-item"><div class="qual-label">' + lab + '</div><div class="qual-text">' + escapeHtml(qualitative[k]) + '</div></div>';
789
+ }
790
+ }
791
+ if (qualitative?.key_turning_point) {
792
+ const ktp = qualitative.key_turning_point;
793
+ qualHtml += '<div class="qual-ktp"><div class="qual-label" style="color:#ffab40">Key Turning Point (Turn ' + (ktp.turn || '?') + ')</div><div class="qual-text">' + escapeHtml(ktp.description || '') + '</div></div>';
794
+ }
795
+ if (qualitative?.tags?.length) {
796
+ qualHtml += '<div class="qual-tags">' + qualitative.tags.map(t => '<span class="tag">' + escapeHtml(t) + '</span>').join('') + '</div>';
797
+ }
798
+
799
+ const scoreStr = scores?.overall?.toFixed(1) || '--';
800
+ document.getElementById('judgePanel').innerHTML =
801
+ '<details class="judge-panel">' +
802
+ '<summary class="judge-toggle">Judge Adjudication — ' + scoreStr + '/100</summary>' +
803
+ '<div class="judge-body">' +
804
+ (judgeRows ? '<table class="judge-table">' + judgeRows + '</table>' : '') +
805
+ (qualHtml ? '<div style="margin-top:16px">' + qualHtml + '</div>' : '') +
806
+ '</div></details>';
807
+ }
808
+
809
+ // ── Highlight / keyboard nav ────────────────────────────────────────────────
810
+ function highlight(idx) {
811
+ document.querySelectorAll('.arrow-group.active').forEach(g => g.classList.remove('active'));
812
+ document.querySelectorAll('.entry.active').forEach(e => e.classList.remove('active'));
813
+
814
+ const arrow = document.querySelector('.arrow-group[data-step="' + idx + '"]');
815
+ const entry = document.getElementById('entry-' + idx);
816
+ if (arrow) arrow.classList.add('active');
817
+ if (entry) {
818
+ entry.classList.add('active');
819
+ entry.scrollIntoView({ behavior: 'smooth', block: 'center' });
820
+ }
821
+ if (arrow) {
822
+ const rect = arrow.getBoundingClientRect();
823
+ const pane = document.querySelector('.left-pane');
824
+ const paneRect = pane.getBoundingClientRect();
825
+ if (rect.top < paneRect.top + 60 || rect.bottom > paneRect.bottom - 20) {
826
+ const y = pane.scrollTop + rect.top - paneRect.top - paneRect.height / 2;
827
+ pane.scrollTo({ top: y, behavior: 'smooth' });
828
+ }
829
+ }
830
+ activeStep = idx;
831
+ }
832
+
833
+ document.addEventListener('keydown', e => {
834
+ if (!totalSteps) return;
835
+ if (e.target.tagName === 'INPUT') return;
836
+ if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); highlight(Math.min(activeStep + 1, totalSteps - 1)); }
837
+ if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); highlight(Math.max(activeStep - 1, 0)); }
838
+ });
839
+
840
+ // ── Init ────────────────────────────────────────────────────────────────────
841
+ loadRuns();
842
+ </script>
843
+ </body>
844
+ </html>`;
845
+
846
+ // ── Start server ────────────────────────────────────────────────────────────
847
+
848
+ app.listen(PORT, () => {
849
+ console.log(`Transcript Browser running at http://localhost:${PORT}`);
850
+ if (shouldOpen) {
851
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
852
+ exec(`${cmd} http://localhost:${PORT}`);
853
+ }
854
+ });