@machinespirits/eval 0.2.1 → 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.
- package/README.md +91 -9
- package/config/eval-settings.yaml +3 -3
- package/config/paper-manifest.json +486 -0
- package/config/providers.yaml +9 -6
- package/config/tutor-agents.yaml +2261 -0
- package/content/README.md +23 -0
- package/content/courses/479/course.md +53 -0
- package/content/courses/479/lecture-1.md +361 -0
- package/content/courses/479/lecture-2.md +360 -0
- package/content/courses/479/lecture-3.md +655 -0
- package/content/courses/479/lecture-4.md +530 -0
- package/content/courses/479/lecture-5.md +326 -0
- package/content/courses/479/lecture-6.md +346 -0
- package/content/courses/479/lecture-7.md +326 -0
- package/content/courses/479/lecture-8.md +273 -0
- package/content/courses/479/roadmap-slides.md +656 -0
- package/content/manifest.yaml +8 -0
- package/docs/research/apa.csl +2133 -0
- package/docs/research/build.sh +98 -0
- package/docs/research/figures/figure1.png +0 -0
- package/docs/research/figures/figure10.png +0 -0
- package/docs/research/figures/figure11.png +0 -0
- package/docs/research/figures/figure2.png +0 -0
- package/docs/research/figures/figure3.png +0 -0
- package/docs/research/figures/figure4.png +0 -0
- package/docs/research/figures/figure5.png +0 -0
- package/docs/research/figures/figure6.png +0 -0
- package/docs/research/figures/figure7.png +0 -0
- package/docs/research/figures/figure8.png +0 -0
- package/docs/research/figures/figure9.png +0 -0
- package/docs/research/header.tex +25 -0
- package/docs/research/paper-full.md +2565 -0
- package/docs/research/paper-short.md +436 -0
- package/docs/research/references.bib +1143 -0
- package/docs/research/slides-header.tex +188 -0
- package/docs/research/slides-pptx.md +363 -0
- package/docs/research/slides.md +531 -0
- package/docs/research/style-reference-pptx.py +199 -0
- package/package.json +5 -5
- package/scripts/analyze-eval-results.js +69 -17
- package/scripts/analyze-mechanism-traces.js +763 -0
- package/scripts/analyze-modulation-learning.js +498 -0
- package/scripts/analyze-prosthesis.js +144 -0
- package/scripts/analyze-run.js +264 -79
- package/scripts/assess-transcripts.js +853 -0
- package/scripts/browse-transcripts.js +854 -0
- package/scripts/check-parse-failures.js +73 -0
- package/scripts/code-dialectical-modulation.js +1320 -0
- package/scripts/download-data.sh +55 -0
- package/scripts/eval-cli.js +106 -18
- package/scripts/generate-paper-figures.js +663 -0
- package/scripts/generate-paper-figures.py +577 -76
- package/scripts/generate-paper-tables.js +299 -0
- package/scripts/qualitative-analysis-ai.js +3 -3
- package/scripts/render-sequence-diagram.js +694 -0
- package/scripts/test-latency.js +210 -0
- package/scripts/test-rate-limit.js +95 -0
- package/scripts/test-token-budget.js +332 -0
- package/scripts/validate-paper-manifest.js +670 -0
- package/services/__tests__/evalConfigLoader.test.js +2 -2
- package/services/__tests__/learnerRubricEvaluator.test.js +361 -0
- package/services/__tests__/learnerTutorInteractionEngine.test.js +326 -0
- package/services/evaluationRunner.js +975 -98
- package/services/evaluationStore.js +12 -4
- package/services/learnerTutorInteractionEngine.js +27 -2
- package/services/mockProvider.js +133 -0
- package/services/promptRewriter.js +1471 -5
- package/services/rubricEvaluator.js +55 -2
- package/services/transcriptFormatter.js +675 -0
- package/config/machinespirits-eval.code-workspace +0 -11
- package/docs/EVALUATION-VARIABLES.md +0 -589
- package/docs/REPLICATION-PLAN.md +0 -577
- package/scripts/analyze-run.mjs +0 -282
- package/scripts/compare-runs.js +0 -44
- package/scripts/compare-suggestions.js +0 -80
- package/scripts/dig-into-run.js +0 -158
- package/scripts/show-failed-suggestions.js +0 -64
- /package/scripts/{check-run.mjs → check-run.js} +0 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate Paper Figures
|
|
5
|
+
*
|
|
6
|
+
* Produces print-ready HTML figures from evaluation dialogues:
|
|
7
|
+
* 1. Light-themed sequence diagrams (SVG) for bilateral multi-turn dialogues
|
|
8
|
+
* 2. Transcript excerpt panels for single-turn or multi-turn dialogues
|
|
9
|
+
* 3. Side-by-side comparison panels (base vs recognition)
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/generate-paper-figures.js <dialogueId> [options]
|
|
13
|
+
* node scripts/generate-paper-figures.js --compare <id1> <id2> [options]
|
|
14
|
+
* node scripts/generate-paper-figures.js --run <runId> --scenario <id> [options]
|
|
15
|
+
*
|
|
16
|
+
* Options:
|
|
17
|
+
* --output <dir> Output directory (default: exports/paper-figures)
|
|
18
|
+
* --format <type> Output format: html, png, both (default: both)
|
|
19
|
+
* --width <px> Figure width (default: 1000)
|
|
20
|
+
* --open Open first figure in browser
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import { execSync } from 'child_process';
|
|
26
|
+
import Database from 'better-sqlite3';
|
|
27
|
+
import YAML from 'yaml';
|
|
28
|
+
|
|
29
|
+
const DB_PATH = path.join(import.meta.dirname, '..', 'data', 'evaluations.db');
|
|
30
|
+
const LOGS_DIR = path.join(import.meta.dirname, '..', 'logs', 'tutor-dialogues');
|
|
31
|
+
const DEFAULT_OUTPUT = path.join(import.meta.dirname, '..', 'exports', 'paper-figures');
|
|
32
|
+
|
|
33
|
+
// ── CLI parsing ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
37
|
+
console.log(`
|
|
38
|
+
Usage:
|
|
39
|
+
generate-paper-figures.js <dialogueId> Single dialogue figure
|
|
40
|
+
generate-paper-figures.js --compare <id1> <id2> Side-by-side comparison
|
|
41
|
+
generate-paper-figures.js --run <runId> --scenario <id> All dialogues for scenario in run
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
--output <dir> Output directory (default: exports/paper-figures)
|
|
45
|
+
--format <type> html, png, both (default: both)
|
|
46
|
+
--width <px> Figure width (default: 1000)
|
|
47
|
+
--open Open in browser
|
|
48
|
+
--transcript-only Skip sequence diagram, only render transcript
|
|
49
|
+
`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getOption(name) {
|
|
54
|
+
const idx = args.indexOf('--' + name);
|
|
55
|
+
return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : null;
|
|
56
|
+
}
|
|
57
|
+
function getFlag(name) { return args.includes('--' + name); }
|
|
58
|
+
|
|
59
|
+
const outputDir = getOption('output') || DEFAULT_OUTPUT;
|
|
60
|
+
const format = getOption('format') || 'both';
|
|
61
|
+
const figWidth = parseInt(getOption('width') || '1000');
|
|
62
|
+
const shouldOpen = getFlag('open');
|
|
63
|
+
const transcriptOnly = getFlag('transcript-only');
|
|
64
|
+
const isCompare = getFlag('compare');
|
|
65
|
+
const runFilter = getOption('run');
|
|
66
|
+
const scenarioFilter = getOption('scenario');
|
|
67
|
+
|
|
68
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
69
|
+
const db = new Database(DB_PATH, { readonly: true });
|
|
70
|
+
|
|
71
|
+
// ── Data loading ─────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function loadDialogue(dialogueId) {
|
|
74
|
+
const row = db.prepare(`
|
|
75
|
+
SELECT id, run_id, profile_name, scenario_id, dialogue_id,
|
|
76
|
+
overall_score, judge_model, ego_model, superego_model,
|
|
77
|
+
score_relevance, score_specificity, score_pedagogical,
|
|
78
|
+
score_personalization, score_actionability, score_tone,
|
|
79
|
+
scores_with_reasoning, qualitative_assessment, suggestions
|
|
80
|
+
FROM evaluation_results
|
|
81
|
+
WHERE dialogue_id = ? AND judge_model = 'claude-opus-4.6'
|
|
82
|
+
ORDER BY overall_score DESC LIMIT 1
|
|
83
|
+
`).get(dialogueId);
|
|
84
|
+
|
|
85
|
+
if (!row) {
|
|
86
|
+
console.error(`No scored dialogue found: ${dialogueId}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Load trace from log file
|
|
91
|
+
const logFiles = fs.readdirSync(LOGS_DIR).filter(f => f.includes(dialogueId));
|
|
92
|
+
let trace = [];
|
|
93
|
+
let log = {};
|
|
94
|
+
if (logFiles.length > 0) {
|
|
95
|
+
log = JSON.parse(fs.readFileSync(path.join(LOGS_DIR, logFiles[0]), 'utf8'));
|
|
96
|
+
trace = log.consolidatedTrace || log.dialogueTrace || [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Detect multi-turn: look for turn_action events (learner followups)
|
|
100
|
+
const isMultiTurn = trace.some(e => e.agent === 'user' && e.action === 'turn_action');
|
|
101
|
+
|
|
102
|
+
return { row, trace, log, isMultiTurn };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadByRunScenario(runId, scenarioId) {
|
|
106
|
+
const rows = db.prepare(`
|
|
107
|
+
SELECT dialogue_id FROM evaluation_results
|
|
108
|
+
WHERE run_id = ? AND scenario_id LIKE ? AND judge_model = 'claude-opus-4.6'
|
|
109
|
+
AND overall_score IS NOT NULL
|
|
110
|
+
ORDER BY overall_score DESC
|
|
111
|
+
`).all(runId, '%' + scenarioId + '%');
|
|
112
|
+
return rows.map(r => r.dialogue_id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── HTML helpers ─────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function esc(text) {
|
|
118
|
+
if (!text) return '';
|
|
119
|
+
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shortModel(m) {
|
|
123
|
+
if (!m) return '—';
|
|
124
|
+
return String(m).replace(/^openrouter\./, '').split('/').pop().split(':')[0];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function scenarioLabel(id) {
|
|
128
|
+
return (id || '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scoreColor(s) {
|
|
132
|
+
if (s >= 85) return '#1b5e20';
|
|
133
|
+
if (s >= 70) return '#e65100';
|
|
134
|
+
return '#b71c1c';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function conditionLabel(profile) {
|
|
138
|
+
if (/recog/i.test(profile)) return 'Recognition';
|
|
139
|
+
if (/naive/i.test(profile)) return 'Naive';
|
|
140
|
+
if (/enhanced/i.test(profile)) return 'Enhanced';
|
|
141
|
+
if (/placebo/i.test(profile)) return 'Placebo';
|
|
142
|
+
return 'Base';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Sequence diagram SVG (light theme) ───────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
function buildSequenceSvg(trace, meta) {
|
|
148
|
+
const actors = [
|
|
149
|
+
{ id: 'learner_superego', label: 'L.Superego', color: '#fce4ec', textColor: '#c62828', stroke: '#ef5350' },
|
|
150
|
+
{ id: 'learner_ego', label: 'L.Ego', color: '#f3e5f5', textColor: '#6a1b9a', stroke: '#ab47bc' },
|
|
151
|
+
{ id: 'tutor_ego', label: 'T.Ego', color: '#e3f2fd', textColor: '#1565c0', stroke: '#42a5f5' },
|
|
152
|
+
{ id: 'tutor_superego', label: 'T.Superego', color: '#e8f5e9', textColor: '#2e7d32', stroke: '#66bb6a' },
|
|
153
|
+
];
|
|
154
|
+
const colMap = {};
|
|
155
|
+
actors.forEach((a, i) => { colMap[a.id] = i; });
|
|
156
|
+
|
|
157
|
+
const steps = traceToSteps(trace);
|
|
158
|
+
if (steps.length === 0) return { svg: '', steps };
|
|
159
|
+
|
|
160
|
+
const colWidth = 130;
|
|
161
|
+
const rowHeight = 34;
|
|
162
|
+
const headerHeight = 48;
|
|
163
|
+
const padding = 16;
|
|
164
|
+
const svgWidth = colWidth * actors.length + padding * 2;
|
|
165
|
+
const svgHeight = headerHeight + steps.length * rowHeight + 20;
|
|
166
|
+
|
|
167
|
+
let svg = `<svg width="${svgWidth}" height="${svgHeight}" xmlns="http://www.w3.org/2000/svg" style="font-family:'Helvetica Neue',Arial,sans-serif">`;
|
|
168
|
+
svg += `<rect width="${svgWidth}" height="${svgHeight}" fill="white"/>`;
|
|
169
|
+
|
|
170
|
+
// Column headers
|
|
171
|
+
actors.forEach((a, i) => {
|
|
172
|
+
const x = padding + i * colWidth;
|
|
173
|
+
const cx = x + colWidth / 2;
|
|
174
|
+
svg += `<rect x="${x + 6}" y="4" width="${colWidth - 12}" height="34" rx="4" fill="${a.color}" stroke="${a.stroke}" stroke-width="1"/>`;
|
|
175
|
+
svg += `<text x="${cx}" y="20" text-anchor="middle" font-size="10" font-weight="600" fill="${a.textColor}">${a.label}</text>`;
|
|
176
|
+
svg += `<text x="${cx}" y="32" text-anchor="middle" font-size="7.5" fill="${a.textColor}" opacity="0.6">${shortModel(a.id.startsWith('learner') ? meta.learnerModel : (a.id === 'tutor_ego' ? meta.egoModel : meta.superegoModel))}</text>`;
|
|
177
|
+
svg += `<line x1="${cx}" y1="${headerHeight}" x2="${cx}" y2="${svgHeight - 10}" stroke="#ddd" stroke-width="1" stroke-dasharray="3,3"/>`;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Turn separators
|
|
181
|
+
let prevTurn = '';
|
|
182
|
+
steps.forEach((s, i) => {
|
|
183
|
+
if (s.label.startsWith('Turn ') || s.label === 'Initial query') {
|
|
184
|
+
const num = s.label === 'Initial query' ? 1 : parseInt(s.label.replace('Turn ', ''));
|
|
185
|
+
if (num !== prevTurn) {
|
|
186
|
+
const y = headerHeight + i * rowHeight;
|
|
187
|
+
svg += `<line x1="${padding}" y1="${y}" x2="${svgWidth - padding}" y2="${y}" stroke="#e0e0e0" stroke-width="0.5"/>`;
|
|
188
|
+
svg += `<text x="${svgWidth - padding + 3}" y="${y + 10}" font-size="8" fill="#999" font-weight="600">T${num}</text>`;
|
|
189
|
+
prevTurn = num;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Arrows
|
|
195
|
+
steps.forEach((step, i) => {
|
|
196
|
+
const fromCol = colMap[step.from];
|
|
197
|
+
const toCol = colMap[step.to];
|
|
198
|
+
if (fromCol === undefined || toCol === undefined) return;
|
|
199
|
+
|
|
200
|
+
const fromX = padding + fromCol * colWidth + colWidth / 2;
|
|
201
|
+
const toX = padding + toCol * colWidth + colWidth / 2;
|
|
202
|
+
const y = headerHeight + i * rowHeight + rowHeight / 2;
|
|
203
|
+
const isLR = fromX < toX;
|
|
204
|
+
|
|
205
|
+
let color;
|
|
206
|
+
if (step.type === 'front' || step.type === 'response') { color = '#78909c'; }
|
|
207
|
+
else { color = actors[fromCol].stroke; }
|
|
208
|
+
|
|
209
|
+
const sw = (step.type === 'front' || step.type === 'response') ? 2 : 1;
|
|
210
|
+
const tipOff = isLR ? -5 : 5;
|
|
211
|
+
|
|
212
|
+
svg += `<line x1="${fromX}" y1="${y}" x2="${toX + tipOff}" y2="${y}" stroke="${color}" stroke-width="${sw}"/>`;
|
|
213
|
+
if (isLR) {
|
|
214
|
+
svg += `<polygon points="${toX - 5},${y - 3} ${toX},${y} ${toX - 5},${y + 3}" fill="${color}"/>`;
|
|
215
|
+
} else {
|
|
216
|
+
svg += `<polygon points="${toX + 5},${y - 3} ${toX},${y} ${toX + 5},${y + 3}" fill="${color}"/>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const labelX = (fromX + toX) / 2;
|
|
220
|
+
let labelColor = '#666';
|
|
221
|
+
if (step.approved === true) labelColor = '#2e7d32';
|
|
222
|
+
if (step.approved === false) labelColor = '#e65100';
|
|
223
|
+
|
|
224
|
+
svg += `<text x="${labelX}" y="${y - 5}" text-anchor="middle" font-size="8.5" font-weight="500" fill="${labelColor}">${esc(step.label)}</text>`;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
svg += '</svg>';
|
|
228
|
+
return { svg, steps };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Trace parser (shared with render-sequence-diagram.js) ────────────────────
|
|
232
|
+
|
|
233
|
+
function extractLearnerQuery(entry) {
|
|
234
|
+
const raw = entry.rawContext || '';
|
|
235
|
+
const match = raw.match(/Learner Messages?:\s*(.+?)(?:\n<\/|$)/s)
|
|
236
|
+
|| raw.match(/Recent Chat History\n-\s*User:\s*"(.+?)"/s);
|
|
237
|
+
return match ? match[1].trim() : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function fullContent(entry) {
|
|
241
|
+
if (entry.agent === 'superego' && entry.action === 'review') {
|
|
242
|
+
return entry.feedback || entry.verdict?.feedback || '';
|
|
243
|
+
}
|
|
244
|
+
if (entry.suggestions?.length > 0) {
|
|
245
|
+
return entry.suggestions.map(s => s.message || s.text || s.title || '').join('\n\n');
|
|
246
|
+
}
|
|
247
|
+
if (entry.agent === 'user' && entry.action === 'context_input') {
|
|
248
|
+
return extractLearnerQuery(entry) || '(scenario context)';
|
|
249
|
+
}
|
|
250
|
+
if (entry.agent === 'user' && entry.action === 'turn_action') {
|
|
251
|
+
return entry.contextSummary || entry.detail || '';
|
|
252
|
+
}
|
|
253
|
+
return entry.detail || entry.contextSummary || '';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function traceToSteps(trace) {
|
|
257
|
+
const steps = [];
|
|
258
|
+
let dialogueTurn = 0;
|
|
259
|
+
const learnerBlockStarts = new Set();
|
|
260
|
+
trace.forEach((e, i) => { if (e.agent === 'learner_ego_initial') learnerBlockStarts.add(i); });
|
|
261
|
+
let needsResponseArrow = false;
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < trace.length; i++) {
|
|
264
|
+
const e = trace[i];
|
|
265
|
+
const { agent, action } = e;
|
|
266
|
+
|
|
267
|
+
if (learnerBlockStarts.has(i) && needsResponseArrow) {
|
|
268
|
+
let responseContent = '';
|
|
269
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
270
|
+
const prev = trace[j];
|
|
271
|
+
if (prev.agent === 'ego' && (prev.action === 'generate' || prev.action === 'revise' || prev.action === 'incorporate-feedback')) {
|
|
272
|
+
responseContent = fullContent(prev);
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
steps.push({ from: 'tutor_ego', to: 'learner_ego', label: 'Response', fullDetail: responseContent, type: 'response', speaker: 'TUTOR EGO' });
|
|
277
|
+
needsResponseArrow = false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (agent === 'system' || (agent === 'user' && action === 'final_output') || agent === 'learner_synthesis') continue;
|
|
281
|
+
|
|
282
|
+
if (agent === 'user' && action === 'context_input') {
|
|
283
|
+
dialogueTurn++;
|
|
284
|
+
if (dialogueTurn === 1) {
|
|
285
|
+
const query = extractLearnerQuery(e);
|
|
286
|
+
const full = query || '(scenario prompt)';
|
|
287
|
+
steps.push({ from: 'learner_ego', to: 'tutor_ego', label: 'Initial query', fullDetail: full, type: 'front', speaker: 'LEARNER' });
|
|
288
|
+
}
|
|
289
|
+
needsResponseArrow = true;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (agent === 'ego' && (action === 'generate' || action === 'revise' || action === 'incorporate-feedback')) {
|
|
294
|
+
const full = fullContent(e);
|
|
295
|
+
let superegoFollows = false;
|
|
296
|
+
for (let j = i + 1; j < trace.length; j++) {
|
|
297
|
+
if (trace[j].agent === 'superego' && trace[j].action === 'review') { superegoFollows = true; break; }
|
|
298
|
+
if (learnerBlockStarts.has(j) || (trace[j].agent === 'user' && trace[j].action === 'context_input')) break;
|
|
299
|
+
}
|
|
300
|
+
if (action !== 'generate' && !superegoFollows) {
|
|
301
|
+
steps.push({ from: 'tutor_ego', to: 'learner_ego', label: 'Response', fullDetail: full, type: 'response', speaker: 'TUTOR EGO' });
|
|
302
|
+
needsResponseArrow = false;
|
|
303
|
+
} else {
|
|
304
|
+
const label = action === 'generate' ? 'Draft' : 'Revised';
|
|
305
|
+
steps.push({ from: 'tutor_ego', to: 'tutor_superego', label, fullDetail: full, type: 'back', speaker: action === 'generate' ? 'TUTOR EGO (draft)' : 'TUTOR EGO (revised)' });
|
|
306
|
+
}
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (agent === 'superego' && action === 'review') {
|
|
311
|
+
const approved = e.approved;
|
|
312
|
+
const full = fullContent(e);
|
|
313
|
+
steps.push({
|
|
314
|
+
from: approved ? 'tutor_superego' : 'tutor_superego', to: 'tutor_ego',
|
|
315
|
+
label: approved ? 'Approved \u2713' : 'Revise \u21BB',
|
|
316
|
+
fullDetail: full, type: 'back', approved, speaker: 'SUPEREGO',
|
|
317
|
+
});
|
|
318
|
+
if (approved) {
|
|
319
|
+
let responseContent = '';
|
|
320
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
321
|
+
const prev = trace[j];
|
|
322
|
+
if (prev.agent === 'ego' && (prev.action === 'generate' || prev.action === 'revise' || prev.action === 'incorporate-feedback')) {
|
|
323
|
+
responseContent = fullContent(prev);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
steps.push({ from: 'tutor_ego', to: 'learner_ego', label: 'Response', fullDetail: responseContent, type: 'response', speaker: 'TUTOR EGO' });
|
|
328
|
+
needsResponseArrow = false;
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (agent === 'learner_ego_initial' && action === 'deliberation') {
|
|
334
|
+
steps.push({ from: 'learner_ego', to: 'learner_superego', label: 'Reaction', fullDetail: fullContent(e), type: 'back', speaker: 'LEARNER EGO' });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (agent === 'learner_superego' && action === 'deliberation') {
|
|
338
|
+
steps.push({ from: 'learner_superego', to: 'learner_ego', label: 'Critique', fullDetail: fullContent(e), type: 'back', speaker: 'LEARNER SUPEREGO' });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (agent === 'learner_ego_revision') continue;
|
|
342
|
+
if (agent === 'user' && action === 'turn_action') {
|
|
343
|
+
steps.push({ from: 'learner_ego', to: 'tutor_ego', label: 'Turn ' + (dialogueTurn + 1), fullDetail: fullContent(e), type: 'front', speaker: 'LEARNER' });
|
|
344
|
+
needsResponseArrow = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return steps;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Single-turn transcript panel ─────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
function buildSingleTurnHtml(data) {
|
|
354
|
+
const { row } = data;
|
|
355
|
+
let suggestions = [];
|
|
356
|
+
try { suggestions = JSON.parse(row.suggestions || '[]'); } catch {}
|
|
357
|
+
|
|
358
|
+
const cond = conditionLabel(row.profile_name);
|
|
359
|
+
const score = row.overall_score?.toFixed(1) || '--';
|
|
360
|
+
|
|
361
|
+
let scoresHtml = '';
|
|
362
|
+
try {
|
|
363
|
+
const scores = JSON.parse(row.scores_with_reasoning || '{}');
|
|
364
|
+
for (const [dim, info] of Object.entries(scores)) {
|
|
365
|
+
const label = dim.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
366
|
+
const s = info.score || 0;
|
|
367
|
+
const barColor = s >= 4 ? '#4caf50' : s >= 3 ? '#ff9800' : '#f44336';
|
|
368
|
+
scoresHtml += `<div class="score-row">
|
|
369
|
+
<span class="score-dim">${label}</span>
|
|
370
|
+
<span class="score-val" style="color:${barColor}">${s}/5</span>
|
|
371
|
+
<div class="score-bar"><div class="score-fill" style="width:${(s/5)*100}%;background:${barColor}"></div></div>
|
|
372
|
+
</div>`;
|
|
373
|
+
}
|
|
374
|
+
} catch {}
|
|
375
|
+
|
|
376
|
+
const suggestion = suggestions[0] || {};
|
|
377
|
+
const messageHtml = esc(suggestion.message || '(no suggestion)');
|
|
378
|
+
const titleHtml = esc(suggestion.title || '');
|
|
379
|
+
const typeHtml = esc(suggestion.type || '');
|
|
380
|
+
const reasoningHtml = esc(suggestion.reasoning || '');
|
|
381
|
+
|
|
382
|
+
return `<div class="panel">
|
|
383
|
+
<div class="panel-header">
|
|
384
|
+
<div class="panel-title">
|
|
385
|
+
<span class="cond-badge ${cond.toLowerCase()}">${cond}</span>
|
|
386
|
+
<span class="scenario-name">${scenarioLabel(row.scenario_id)}</span>
|
|
387
|
+
</div>
|
|
388
|
+
<div class="panel-score" style="background:${scoreColor(parseFloat(score))}">${score}</div>
|
|
389
|
+
</div>
|
|
390
|
+
<div class="panel-meta">
|
|
391
|
+
<span>Cell: ${esc(row.profile_name)}</span>
|
|
392
|
+
<span>Model: ${shortModel(row.ego_model)}</span>
|
|
393
|
+
</div>
|
|
394
|
+
<div class="suggestion">
|
|
395
|
+
<div class="sugg-header">
|
|
396
|
+
<span class="sugg-type">${typeHtml}</span>
|
|
397
|
+
<span class="sugg-title">${titleHtml}</span>
|
|
398
|
+
</div>
|
|
399
|
+
<div class="sugg-message">${messageHtml}</div>
|
|
400
|
+
${reasoningHtml ? `<div class="sugg-reasoning"><em>Reasoning:</em> ${reasoningHtml}</div>` : ''}
|
|
401
|
+
</div>
|
|
402
|
+
${scoresHtml ? `<div class="scores">${scoresHtml}</div>` : ''}
|
|
403
|
+
</div>`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Multi-turn transcript panel ──────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
function buildMultiTurnHtml(data) {
|
|
409
|
+
const { row, trace } = data;
|
|
410
|
+
const steps = traceToSteps(trace);
|
|
411
|
+
const cond = conditionLabel(row.profile_name);
|
|
412
|
+
const score = row.overall_score?.toFixed(1) || '--';
|
|
413
|
+
|
|
414
|
+
// Select key moments: initial query, responses, learner turns
|
|
415
|
+
const keySteps = steps.filter(s =>
|
|
416
|
+
s.type === 'front' || s.type === 'response' ||
|
|
417
|
+
(s.approved === false) || (s.speaker === 'LEARNER EGO') || (s.speaker === 'LEARNER SUPEREGO')
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const speakerColors = {
|
|
421
|
+
'TUTOR EGO': '#1565c0', 'TUTOR EGO (draft)': '#1565c0', 'TUTOR EGO (revised)': '#1565c0',
|
|
422
|
+
'SUPEREGO': '#2e7d32', 'LEARNER EGO': '#6a1b9a', 'LEARNER SUPEREGO': '#c62828',
|
|
423
|
+
'LEARNER': '#455a64',
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
let excerptHtml = '';
|
|
427
|
+
const maxExcerpts = 8;
|
|
428
|
+
const selected = keySteps.slice(0, maxExcerpts);
|
|
429
|
+
for (const step of selected) {
|
|
430
|
+
const color = speakerColors[step.speaker] || '#666';
|
|
431
|
+
const content = (step.fullDetail || '').substring(0, 300) + ((step.fullDetail || '').length > 300 ? '...' : '');
|
|
432
|
+
excerptHtml += `<div class="excerpt">
|
|
433
|
+
<div class="excerpt-speaker" style="color:${color}">${esc(step.speaker)} <span class="excerpt-label">${esc(step.label)}</span></div>
|
|
434
|
+
<div class="excerpt-content">${esc(content)}</div>
|
|
435
|
+
</div>`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return `<div class="panel">
|
|
439
|
+
<div class="panel-header">
|
|
440
|
+
<div class="panel-title">
|
|
441
|
+
<span class="cond-badge ${cond.toLowerCase()}">${cond}</span>
|
|
442
|
+
<span class="scenario-name">${scenarioLabel(row.scenario_id)}</span>
|
|
443
|
+
<span class="turn-count">${data.log.totalTurns || '?'} turns</span>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="panel-score" style="background:${scoreColor(parseFloat(score))}">${score}</div>
|
|
446
|
+
</div>
|
|
447
|
+
<div class="panel-meta">
|
|
448
|
+
<span>Cell: ${esc(row.profile_name)}</span>
|
|
449
|
+
<span>Ego: ${shortModel(row.ego_model)}</span>
|
|
450
|
+
${row.superego_model ? `<span>Superego: ${shortModel(row.superego_model)}</span>` : ''}
|
|
451
|
+
</div>
|
|
452
|
+
<div class="excerpts">${excerptHtml}</div>
|
|
453
|
+
</div>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── Full page wrapper ────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
function wrapPage(title, bodyHtml, svgHtml = '') {
|
|
459
|
+
return `<!DOCTYPE html>
|
|
460
|
+
<html lang="en">
|
|
461
|
+
<head>
|
|
462
|
+
<meta charset="UTF-8">
|
|
463
|
+
<title>${esc(title)}</title>
|
|
464
|
+
<style>
|
|
465
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
466
|
+
body { font-family: 'Helvetica Neue', Arial, sans-serif; background: white; color: #222; padding: 24px; max-width: ${figWidth}px; }
|
|
467
|
+
h1 { font-size: 16px; font-weight: 700; margin-bottom: 16px; color: #111; border-bottom: 2px solid #111; padding-bottom: 8px; }
|
|
468
|
+
|
|
469
|
+
.figure-row { display: flex; gap: 20px; margin-bottom: 24px; }
|
|
470
|
+
.figure-row > * { flex: 1; min-width: 0; }
|
|
471
|
+
.figure-single { margin-bottom: 24px; }
|
|
472
|
+
|
|
473
|
+
.svg-container { margin-bottom: 20px; overflow-x: auto; }
|
|
474
|
+
.svg-container svg { display: block; max-width: 100%; }
|
|
475
|
+
|
|
476
|
+
.panel { border: 1px solid #e0e0e0; border-radius: 8px; padding: 16px; background: #fafafa; }
|
|
477
|
+
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
478
|
+
.panel-title { display: flex; align-items: center; gap: 8px; }
|
|
479
|
+
.panel-score { color: white; font-weight: 700; font-size: 16px; padding: 4px 12px; border-radius: 12px; }
|
|
480
|
+
.panel-meta { font-size: 11px; color: #888; margin-bottom: 12px; display: flex; gap: 12px; }
|
|
481
|
+
|
|
482
|
+
.cond-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
483
|
+
.cond-badge.recognition { background: #e8f5e9; color: #2e7d32; }
|
|
484
|
+
.cond-badge.base { background: #fafafa; color: #666; border: 1px solid #ddd; }
|
|
485
|
+
.cond-badge.naive { background: #fff3e0; color: #e65100; }
|
|
486
|
+
|
|
487
|
+
.scenario-name { font-size: 14px; font-weight: 600; }
|
|
488
|
+
.turn-count { font-size: 11px; color: #999; }
|
|
489
|
+
|
|
490
|
+
.suggestion { margin-bottom: 12px; }
|
|
491
|
+
.sugg-header { display: flex; gap: 8px; align-items: baseline; margin-bottom: 4px; }
|
|
492
|
+
.sugg-type { font-size: 10px; font-weight: 600; color: #1565c0; text-transform: uppercase; background: #e3f2fd; padding: 1px 6px; border-radius: 4px; }
|
|
493
|
+
.sugg-title { font-size: 13px; font-weight: 600; }
|
|
494
|
+
.sugg-message { font-size: 12px; line-height: 1.6; color: #333; padding: 8px 12px; background: white; border: 1px solid #e8e8e8; border-radius: 6px; }
|
|
495
|
+
.sugg-reasoning { font-size: 11px; color: #888; margin-top: 6px; padding: 6px 12px; background: #f5f5f5; border-radius: 4px; }
|
|
496
|
+
|
|
497
|
+
.scores { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 8px; }
|
|
498
|
+
.score-row { display: flex; align-items: center; gap: 6px; font-size: 10px; }
|
|
499
|
+
.score-dim { width: 90px; color: #666; }
|
|
500
|
+
.score-val { width: 28px; font-weight: 700; text-align: center; }
|
|
501
|
+
.score-bar { flex: 1; height: 4px; background: #eee; border-radius: 2px; }
|
|
502
|
+
.score-fill { height: 4px; border-radius: 2px; }
|
|
503
|
+
|
|
504
|
+
.excerpts { display: flex; flex-direction: column; gap: 8px; }
|
|
505
|
+
.excerpt { padding: 8px 12px; background: white; border: 1px solid #e8e8e8; border-radius: 6px; }
|
|
506
|
+
.excerpt-speaker { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 2px; }
|
|
507
|
+
.excerpt-label { font-weight: 400; color: #999; }
|
|
508
|
+
.excerpt-content { font-size: 11px; line-height: 1.5; color: #444; }
|
|
509
|
+
|
|
510
|
+
.caption { font-size: 11px; color: #666; margin-top: 8px; font-style: italic; }
|
|
511
|
+
</style>
|
|
512
|
+
</head>
|
|
513
|
+
<body>
|
|
514
|
+
<h1>${esc(title)}</h1>
|
|
515
|
+
${svgHtml ? `<div class="svg-container">${svgHtml}</div>` : ''}
|
|
516
|
+
${bodyHtml}
|
|
517
|
+
</body>
|
|
518
|
+
</html>`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── PNG conversion ───────────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
function htmlToPng(htmlPath, pngPath) {
|
|
524
|
+
try {
|
|
525
|
+
execSync(`npx capture-website-cli "file://${htmlPath}" --output "${pngPath}" --width ${figWidth} --full-page --type png --scale-factor 2 --delay 0.5`, { stdio: 'pipe' });
|
|
526
|
+
return true;
|
|
527
|
+
} catch (e) {
|
|
528
|
+
console.error(` ⚠ PNG conversion failed: ${e.message}`);
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
const rendered = [];
|
|
536
|
+
|
|
537
|
+
if (isCompare) {
|
|
538
|
+
// Side-by-side comparison: --compare <id1> <id2>
|
|
539
|
+
const ids = args.filter(a => !a.startsWith('--') && a !== getOption('output') && a !== getOption('format') && a !== getOption('width'));
|
|
540
|
+
if (ids.length < 2) {
|
|
541
|
+
console.error('--compare requires two dialogue IDs');
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const d1 = loadDialogue(ids[0]);
|
|
546
|
+
const d2 = loadDialogue(ids[1]);
|
|
547
|
+
if (!d1 || !d2) process.exit(1);
|
|
548
|
+
|
|
549
|
+
const isMultiTurn1 = d1.isMultiTurn;
|
|
550
|
+
const isMultiTurn2 = d2.isMultiTurn;
|
|
551
|
+
|
|
552
|
+
const title = `${scenarioLabel(d1.row.scenario_id)}: ${conditionLabel(d1.row.profile_name)} vs ${conditionLabel(d2.row.profile_name)}`;
|
|
553
|
+
|
|
554
|
+
let svgHtml = '';
|
|
555
|
+
if (!transcriptOnly && isMultiTurn1 && isMultiTurn2) {
|
|
556
|
+
const svg1 = buildSequenceSvg(d1.trace, { egoModel: d1.row.ego_model, superegoModel: d1.row.superego_model, learnerModel: '' });
|
|
557
|
+
const svg2 = buildSequenceSvg(d2.trace, { egoModel: d2.row.ego_model, superegoModel: d2.row.superego_model, learnerModel: '' });
|
|
558
|
+
svgHtml = `<div class="figure-row"><div>${svg1.svg}</div><div>${svg2.svg}</div></div>`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const panel1 = isMultiTurn1 ? buildMultiTurnHtml(d1) : buildSingleTurnHtml(d1);
|
|
562
|
+
const panel2 = isMultiTurn2 ? buildMultiTurnHtml(d2) : buildSingleTurnHtml(d2);
|
|
563
|
+
const bodyHtml = `<div class="figure-row">${panel1}${panel2}</div>`;
|
|
564
|
+
|
|
565
|
+
const html = wrapPage(title, bodyHtml, svgHtml);
|
|
566
|
+
const cond1 = conditionLabel(d1.row.profile_name).toLowerCase();
|
|
567
|
+
const cond2 = conditionLabel(d2.row.profile_name).toLowerCase();
|
|
568
|
+
const baseName = `compare-${d1.row.scenario_id}-${cond1}-vs-${cond2}`;
|
|
569
|
+
const htmlPath = path.join(outputDir, `${baseName}.html`);
|
|
570
|
+
fs.writeFileSync(htmlPath, html);
|
|
571
|
+
rendered.push(htmlPath);
|
|
572
|
+
console.log(` ✓ ${baseName}.html`);
|
|
573
|
+
|
|
574
|
+
if (format === 'png' || format === 'both') {
|
|
575
|
+
const pngPath = path.join(outputDir, `${baseName}.png`);
|
|
576
|
+
if (htmlToPng(htmlPath, pngPath)) {
|
|
577
|
+
rendered.push(pngPath);
|
|
578
|
+
console.log(` ✓ ${baseName}.png`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
} else if (runFilter && scenarioFilter) {
|
|
583
|
+
// All dialogues for a run+scenario
|
|
584
|
+
const dialogueIds = loadByRunScenario(runFilter, scenarioFilter);
|
|
585
|
+
console.log(`Found ${dialogueIds.length} dialogues for ${scenarioFilter} in ${runFilter}`);
|
|
586
|
+
|
|
587
|
+
for (const did of dialogueIds) {
|
|
588
|
+
const data = loadDialogue(did);
|
|
589
|
+
if (!data) continue;
|
|
590
|
+
|
|
591
|
+
const isMultiTurn = data.isMultiTurn;
|
|
592
|
+
const title = `${scenarioLabel(data.row.scenario_id)} — ${conditionLabel(data.row.profile_name)} (${data.row.overall_score?.toFixed(1)})`;
|
|
593
|
+
|
|
594
|
+
let svgHtml = '';
|
|
595
|
+
if (!transcriptOnly && isMultiTurn) {
|
|
596
|
+
const { svg } = buildSequenceSvg(data.trace, { egoModel: data.row.ego_model, superegoModel: data.row.superego_model, learnerModel: '' });
|
|
597
|
+
svgHtml = svg;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const panel = isMultiTurn ? buildMultiTurnHtml(data) : buildSingleTurnHtml(data);
|
|
601
|
+
const html = wrapPage(title, `<div class="figure-single">${panel}</div>`, svgHtml);
|
|
602
|
+
|
|
603
|
+
const baseName = `figure-${data.row.profile_name}-${data.row.scenario_id}-${data.row.overall_score?.toFixed(0) || '0'}`;
|
|
604
|
+
const htmlPath = path.join(outputDir, `${baseName}.html`);
|
|
605
|
+
fs.writeFileSync(htmlPath, html);
|
|
606
|
+
rendered.push(htmlPath);
|
|
607
|
+
console.log(` ✓ ${baseName}.html (score ${data.row.overall_score?.toFixed(1)})`);
|
|
608
|
+
|
|
609
|
+
if (format === 'png' || format === 'both') {
|
|
610
|
+
const pngPath = path.join(outputDir, `${baseName}.png`);
|
|
611
|
+
if (htmlToPng(htmlPath, pngPath)) {
|
|
612
|
+
rendered.push(pngPath);
|
|
613
|
+
console.log(` ✓ ${baseName}.png`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
} else {
|
|
619
|
+
// Single dialogue
|
|
620
|
+
const dialogueId = args.find(a => !a.startsWith('--') && a !== getOption('output') && a !== getOption('format') && a !== getOption('width'));
|
|
621
|
+
if (!dialogueId) {
|
|
622
|
+
console.error('Provide a dialogue ID, or use --compare or --run + --scenario');
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const data = loadDialogue(dialogueId);
|
|
627
|
+
if (!data) process.exit(1);
|
|
628
|
+
|
|
629
|
+
const isMultiTurn = data.isMultiTurn;
|
|
630
|
+
const title = `${scenarioLabel(data.row.scenario_id)} — ${conditionLabel(data.row.profile_name)} (${data.row.overall_score?.toFixed(1)})`;
|
|
631
|
+
|
|
632
|
+
let svgHtml = '';
|
|
633
|
+
if (!transcriptOnly && isMultiTurn) {
|
|
634
|
+
const { svg } = buildSequenceSvg(data.trace, { egoModel: data.row.ego_model, superegoModel: data.row.superego_model, learnerModel: '' });
|
|
635
|
+
svgHtml = svg;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const panel = isMultiTurn ? buildMultiTurnHtml(data) : buildSingleTurnHtml(data);
|
|
639
|
+
const html = wrapPage(title, `<div class="figure-single">${panel}</div>`, svgHtml);
|
|
640
|
+
|
|
641
|
+
const baseName = `figure-${data.row.profile_name}-${data.row.scenario_id}-${data.row.overall_score?.toFixed(0) || '0'}`;
|
|
642
|
+
const htmlPath = path.join(outputDir, `${baseName}.html`);
|
|
643
|
+
fs.writeFileSync(htmlPath, html);
|
|
644
|
+
rendered.push(htmlPath);
|
|
645
|
+
console.log(` ✓ ${baseName}.html`);
|
|
646
|
+
|
|
647
|
+
if (format === 'png' || format === 'both') {
|
|
648
|
+
const pngPath = path.join(outputDir, `${baseName}.png`);
|
|
649
|
+
if (htmlToPng(htmlPath, pngPath)) {
|
|
650
|
+
rendered.push(pngPath);
|
|
651
|
+
console.log(` ✓ ${baseName}.png`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
console.log(`\nRendered ${rendered.length} file(s) to ${outputDir}/`);
|
|
657
|
+
|
|
658
|
+
if (shouldOpen && rendered.length > 0) {
|
|
659
|
+
const toOpen = rendered.find(f => f.endsWith('.html')) || rendered[0];
|
|
660
|
+
try { execSync(`open "${toOpen}"`); } catch {}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
db.close();
|