/**
* Renders a single-page "flow" visual from a run-*.json (or ask-*.json)
* audit file. Self-contained HTML — no external assets.
*
* tsx scripts/render-flow.ts # latest run
* tsx scripts/render-flow.ts output/run-...json # specific run
*/
import { readdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUTPUT_DIR = path.resolve(__dirname, '..', 'output');
async function pickLatest(): Promise<string | null> {
const entries = await readdir(OUTPUT_DIR);
const runs = entries
.filter(f => (f.startsWith('run-') || f.startsWith('ask-')) && f.endsWith('.json'))
.sort();
const last = runs.at(-1);
return last ? path.join(OUTPUT_DIR, last) : null;
}
function esc(s: unknown): string {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
/* ---------- per-stage HTML builders ---------- */
interface PersistedRecord {
agent: string;
metadata: Record<string, unknown>;
payload: unknown;
confidence: { value: number; tier: string };
validationStatus: string;
}
interface ExtractedValue {
entity: string;
rawLabel: string;
value: number | null;
rawUnit: string | null;
sourceUrl: string;
shape: string;
snippet?: string;
}
interface NormalizedRec {
canonicalEntity: string;
canonicalMetric: string;
period: string;
value: number | null;
unit: string;
rawEntity: string;
rawLabel: string;
rawValue: number | string | null;
rawUnit: string | null;
confidence: number;
}
interface ResolvedRec extends NormalizedRec {
resolutionAction?: string;
}
function renderJobRequest(job: unknown): string {
if (!job || typeof job !== 'object') {
return '<p class="muted">JobRequest not persisted in this run — re-run <code>npm run demo</code> to regenerate.</p>';
}
return `<pre class="json">${esc(JSON.stringify(job, null, 2))}</pre>`;
}
function renderExtraction(rec: PersistedRecord | undefined): string {
if (!rec) return '<p class="muted">Source/Extraction did not run.</p>';
const payload = rec.payload as { values?: ExtractedValue[]; comparabilityNotes?: string[] } | undefined;
const values = payload?.values ?? [];
const notes = payload?.comparabilityNotes ?? [];
const rows = values.slice(0, 6).map(v => `
<tr>
<td>${esc(v.entity)}</td>
<td>"${esc(v.rawLabel)}"</td>
<td class="num">${v.value === null ? '—' : v.value.toLocaleString()}</td>
<td>${esc(v.rawUnit ?? '—')}</td>
<td><span class="muted">${esc(v.shape)}</span></td>
</tr>`).join('');
const more = values.length > 6 ? `<div class="muted small">… and ${values.length - 6} more</div>` : '';
const notesBlock = notes.length
? `<div class="callout"><strong>Comparability notes for next stage</strong><br>${notes.map(esc).join('<br>')}</div>`
: '';
return `
<p class="caption">${values.length} value(s) extracted from raw payloads, each provenance-stamped.</p>
<table class="grid">
<thead><tr><th>Entity</th><th>Raw label</th><th>Value</th><th>Unit</th><th>Shape</th></tr></thead>
<tbody>${rows}</tbody>
</table>
${more}
${notesBlock}
`;
}
function renderNormalization(rec: PersistedRecord | undefined): string {
if (!rec) return '<p class="muted">Normalization did not run.</p>';
const payload = rec.payload as { records?: NormalizedRec[]; learnedRules?: { key: string; value: string }[] } | undefined;
const records = payload?.records ?? [];
const learned = payload?.learnedRules ?? [];
const rows = records.slice(0, 6).map(r => `
<tr>
<td><strong>${esc(r.canonicalEntity)}</strong><div class="muted small">(raw: ${esc(r.rawEntity)})</div></td>
<td><strong>${esc(r.canonicalMetric)}</strong><div class="muted small">(raw: "${esc(r.rawLabel)}")</div></td>
<td class="num"><strong>${r.value === null ? '—' : r.value.toLocaleString()}</strong> <span class="muted">${esc(r.unit)}</span>
<div class="muted small">raw ${r.rawValue === null ? '—' : esc(r.rawValue)} ${esc(r.rawUnit ?? '')}</div></td>
<td class="num">${r.confidence.toFixed(2)}</td>
</tr>`).join('');
const more = records.length > 6 ? `<div class="muted small">… and ${records.length - 6} more</div>` : '';
const learnedBlock = learned.length
? `<div class="callout"><strong>${learned.length} new rule(s) learned</strong><br>${learned.map(r => `<code>${esc(r.key)}</code> → <code>${esc(r.value)}</code>`).join('<br>')}</div>`
: '';
return `
<p class="caption">Raw labels mapped → canonical metric keys; units converted; entity aliases resolved. Raw values preserved alongside.</p>
<table class="grid">
<thead><tr><th>Entity (canonical)</th><th>Metric (canonical)</th><th>Value</th><th>Conf.</th></tr></thead>
<tbody>${rows}</tbody>
</table>
${more}
${learnedBlock}
`;
}
function renderResolution(rec: PersistedRecord | undefined): string {
if (!rec) return '<p class="muted">Resolution did not run.</p>';
const payload = rec.payload as { records?: ResolvedRec[]; stillUnresolved?: unknown[] } | undefined;
const records = payload?.records ?? [];
const unresolved = payload?.stillUnresolved ?? [];
const rows = records.slice(0, 6).map(r => `
<tr>
<td><strong>${esc(r.canonicalEntity)}</strong></td>
<td>${esc(r.canonicalMetric)}</td>
<td class="num"><strong>${r.value === null ? '—' : r.value.toLocaleString()}</strong> <span class="muted">${esc(r.unit)}</span></td>
<td><span class="action a-${esc(r.resolutionAction ?? 'na')}">${esc(r.resolutionAction ?? '—')}</span></td>
</tr>`).join('');
const more = records.length > 6 ? `<div class="muted small">… and ${records.length - 6} more</div>` : '';
const escBlock = unresolved.length
? `<div class="callout warn"><strong>${unresolved.length} issue(s) escalated for HITL</strong></div>`
: `<div class="callout ok"><strong>No residual issues — all clean.</strong></div>`;
return `
<p class="caption">Duplicates merged, contradictions resolved (preferring stronger lineage). Residuals escalate to HITL.</p>
<table class="grid">
<thead><tr><th>Entity</th><th>Metric</th><th>Value</th><th>Action</th></tr></thead>
<tbody>${rows}</tbody>
</table>
${more}
${escBlock}
`;
}
function renderFinal(data: { finalHandoff?: { payload?: { records?: ResolvedRec[] }; validation?: { status: string }; confidence?: { value: number; tier: string } } }): string {
const records = data.finalHandoff?.payload?.records ?? [];
if (records.length === 0) return '<p class="muted">No final records.</p>';
// Pivot: rows = entity, columns = (metric, period).
const metrics = Array.from(new Set(records.map(r => r.canonicalMetric)));
const periods = Array.from(new Set(records.map(r => r.period)));
const entities = Array.from(new Set(records.map(r => r.canonicalEntity)));
const cell = (e: string, m: string, p: string) => {
const r = records.find(x => x.canonicalEntity === e && x.canonicalMetric === m && x.period === p);
if (!r) return '<td class="num muted">—</td>';
return `<td class="num"><strong>${r.value === null ? '—' : r.value.toLocaleString()}</strong> <span class="muted small">${esc(r.unit)}</span></td>`;
};
const headers = metrics.flatMap(m => periods.map(p => `<th>${esc(m)}<div class="muted small">${esc(p)}</div></th>`)).join('');
const rows = entities.map(e => `
<tr><th class="rowhead">${esc(e)}</th>
${metrics.flatMap(m => periods.map(p => cell(e, m, p))).join('')}
</tr>`).join('');
const conf = data.finalHandoff?.confidence;
const validation = data.finalHandoff?.validation?.status ?? 'passed';
return `
<p class="caption">Analytics-ready dataset, ready for the Intelligence pillar.</p>
<table class="grid pivot">
<thead><tr><th></th>${headers}</tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="callout ok">
<strong>Final handoff:</strong> validation <code>${esc(validation)}</code>
${conf ? ` · confidence <code>${esc(conf.tier)}</code> (${conf.value.toFixed(2)})` : ''}
</div>
`;
}
/* ---------- top-level HTML ---------- */
interface Stage {
id: string;
label: string;
sub: string;
description: string;
tools: string;
content: string;
}
function buildStages(data: {
jobRequest?: unknown;
repositorySnapshot: { records: PersistedRecord[] };
finalHandoff?: { payload?: { records?: ResolvedRec[] }; validation?: { status: string }; confidence?: { value: number; tier: string } };
}): Stage[] {
const records = data.repositorySnapshot.records;
const ex = records.find(r => r.agent === 'baseline.source-extraction');
const nm = records.find(r => r.agent === 'baseline.normalization');
const rs = records.find(r => r.agent === 'baseline.resolution');
const toolsOf = (r?: PersistedRecord) => {
const t = r?.metadata?.toolsUsed;
return Array.isArray(t) ? t.join(', ') : '—';
};
return [
{
id: 'input',
label: 'INPUT',
sub: 'JobRequest',
description: 'The structured instruction the orchestrator accepts. Agents go fetch unstructured data based on these instructions.',
tools: '—',
content: renderJobRequest(data.jobRequest),
},
{
id: 'extraction',
label: 'STAGE 1',
sub: 'Source / Extraction',
description: 'Fetches raw payloads from approved connectors, parses HTML / text / JSON, extracts (label, value, unit) triples with full provenance.',
tools: toolsOf(ex),
content: renderExtraction(ex),
},
{
id: 'normalization',
label: 'STAGE 2',
sub: 'Normalization',
description: 'Maps raw labels to canonical metric keys, converts units, resolves entity aliases. Raw values preserved alongside.',
tools: toolsOf(nm),
content: renderNormalization(nm),
},
{
id: 'resolution',
label: 'STAGE 3',
sub: 'Resolution',
description: 'Resolves duplicates and contradictions. Residual issues escalate to HITL with full context.',
tools: toolsOf(rs),
content: renderResolution(rs),
},
{
id: 'output',
label: 'OUTPUT',
sub: 'Clean dataset',
description: 'Pivot-ready records in the canonical unit. Hands off to the Intelligence pillar next.',
tools: '—',
content: renderFinal(data),
},
];
}
function render(data: Parameters<typeof buildStages>[0] & { analysisId?: string }, sourceFile: string): string {
const stages = buildStages(data);
const analysisId = data.analysisId ?? '(unknown)';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BID flow · ${esc(analysisId)}</title>
<style>
:root {
--fg:#1f2328; --muted:#656d76; --bg:#fff; --soft:#f6f8fa; --border:#d0d7de;
--blue:#0969da; --accent:#8250df; --green:#137333; --amber:#b06000; --red:#a50e0e;
}
* { box-sizing: border-box; }
body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color:var(--fg); background:var(--soft); margin:0; padding:0 24px 64px; }
header.top { padding:24px 0 8px; }
h1 { font-size:22px; margin:0 0 4px; }
.sub { color:var(--muted); }
.controls { position:sticky; top:0; z-index:10; background:var(--soft); padding:10px 0; border-bottom:1px solid var(--border); margin:0 -24px 16px; padding-left:24px; padding-right:24px; display:flex; gap:8px; align-items:center; }
.controls button { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:6px 12px; font:inherit; cursor:pointer; }
.controls button:hover { background:var(--soft); }
.controls button.primary { background:var(--accent); color:#fff; border-color:var(--accent); font-weight:500; }
.controls .step-indicator { color:var(--muted); margin-left:auto; font-variant-numeric:tabular-nums; font-size:12px; }
/* horizontal flow strip */
.flow { display:flex; align-items:stretch; gap:0; flex-wrap:nowrap; overflow-x:auto; margin-bottom:24px; padding:4px 0; }
.node { flex:1 1 0; min-width:140px; background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:10px 12px; cursor:pointer; transition:all 0.2s; text-align:center; }
.node:hover { border-color:var(--accent); }
.node.current { background:#eaeefc; border-color:var(--accent); box-shadow:0 0 0 2px #8250df33; transform:translateY(-1px); }
.node .label { font-size:10px; color:var(--muted); text-transform:uppercase; letter-spacing:.06em; }
.node .sub { color:var(--fg); font-weight:600; margin-top:2px; }
.node .tools { font-size:11px; color:var(--muted); margin-top:4px; }
.arrow { display:flex; align-items:center; justify-content:center; padding:0 6px; color:var(--muted); font-size:18px; flex:0 0 auto; user-select:none; }
/* stage cards */
.stage { background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:20px 24px; margin-bottom:16px; transition:box-shadow 0.25s, border-color 0.25s; }
.stage.current { border-color:var(--accent); box-shadow:0 6px 24px #8250df22; }
.stage h2 { font-size:18px; margin:0 0 4px; display:flex; align-items:baseline; gap:8px; flex-wrap:wrap; }
.stage h2 .badge { background:var(--accent); color:#fff; font-size:11px; padding:2px 8px; border-radius:10px; font-weight:600; letter-spacing:.04em; }
.stage .desc { color:var(--muted); margin:0 0 12px; max-width:80ch; }
.stage .tools-line { font-size:12px; color:var(--muted); margin-bottom:14px; }
.stage .tools-line code { background:var(--soft); padding:1px 6px; border-radius:4px; }
.caption { color:var(--muted); margin:0 0 10px; }
.muted { color:var(--muted); }
.small { font-size:12px; }
pre.json { background:var(--soft); border:1px solid var(--border); border-radius:6px; padding:12px; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px; overflow:auto; max-height:340px; }
table.grid { width:100%; border-collapse:collapse; background:var(--bg); }
table.grid th, table.grid td { padding:8px 10px; border-bottom:1px solid var(--border); text-align:left; vertical-align:top; }
table.grid th { background:var(--soft); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); }
table.grid td.num { font-variant-numeric:tabular-nums; }
table.grid.pivot th.rowhead { background:var(--soft); }
.action { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; font-weight:500; }
.a-pass-through { background:#dafbe1; color:var(--green); }
.a-duplicate-merged { background:#fff8c5; color:#9a6700; }
.a-contradiction-resolved { background:#ffd8b5; color:#b35a00; }
.a-escalated { background:#ffd8d3; color:var(--red); }
.a-rule-applied { background:#ddf4ff; color:var(--blue); }
.a-na { background:var(--soft); color:var(--muted); }
.callout { margin-top:12px; padding:10px 12px; background:var(--soft); border-left:3px solid var(--accent); border-radius:0 6px 6px 0; font-size:13px; }
.callout.ok { border-left-color:var(--green); background:#dafbe122; }
.callout.warn { border-left-color:var(--amber); background:#fff8c522; }
.callout code { background:#fff; padding:1px 6px; border-radius:4px; border:1px solid var(--border); }
a { color:var(--blue); text-decoration:none; }
a:hover { text-decoration:underline; }
.footer { margin-top:24px; color:var(--muted); font-size:12px; }
.footer code { background:var(--bg); padding:1px 6px; border-radius:4px; border:1px solid var(--border); }
</style>
</head>
<body>
<header class="top">
<h1>BID Baseline — Pillar Flow</h1>
<div class="sub">A <code>JobRequest</code> enters, three agents transform it, a clean dataset leaves. ${esc(analysisId)} · <span class="muted small">${esc(path.basename(sourceFile))}</span></div>
</header>
<div class="controls">
<button id="prev" type="button" title="Previous stage">◀ Prev</button>
<button id="play" class="primary" type="button" title="Auto-play">▶ Auto-play</button>
<button id="next" type="button" title="Next stage">Next ▶</button>
<button id="reset" type="button" title="Reset">⏮</button>
<span class="step-indicator" id="indicator">Stage 1 of ${stages.length}</span>
</div>
<div class="flow" id="flow-strip">
${stages
.map(
(s, i) => `
<div class="node" data-stage="${i}">
<div class="label">${esc(s.label)}</div>
<div class="sub">${esc(s.sub)}</div>
<div class="tools">${esc(s.tools)}</div>
</div>
${i < stages.length - 1 ? '<div class="arrow">→</div>' : ''}`,
)
.join('')}
</div>
${stages
.map(
(s, i) => `
<section class="stage" id="stage-${esc(s.id)}" data-stage="${i}">
<h2><span class="badge">${esc(s.label)}</span> ${esc(s.sub)}</h2>
<p class="desc">${esc(s.description)}</p>
<div class="tools-line">Tools (capabilities): <code>${esc(s.tools)}</code></div>
${s.content}
</section>`,
)
.join('')}
<div class="footer">
For a full audit trail of this run, open the report:
<code>${esc(path.basename(sourceFile).replace(/\.json$/, '.html'))}</code>.
For the framework architecture, see <code>FLOW.md</code>.
</div>
<script>
(function () {
const stages = Array.from(document.querySelectorAll('.stage'));
const nodes = Array.from(document.querySelectorAll('.node'));
const indicator = document.getElementById('indicator');
const playBtn = document.getElementById('play');
let cur = 0;
let timer = null;
let playing = false;
function show(i, scroll = true) {
cur = Math.max(0, Math.min(stages.length - 1, i));
stages.forEach((s, j) => s.classList.toggle('current', j === cur));
nodes.forEach((n, j) => n.classList.toggle('current', j === cur));
indicator.textContent = 'Stage ' + (cur + 1) + ' of ' + stages.length;
if (scroll) stages[cur].scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function setPlaying(v) {
playing = v;
playBtn.textContent = v ? '⏸ Pause' : '▶ Auto-play';
if (!v && timer) { clearTimeout(timer); timer = null; }
}
function tick() {
if (!playing) return;
if (cur >= stages.length - 1) { setPlaying(false); return; }
show(cur + 1);
timer = setTimeout(tick, 2800);
}
playBtn.addEventListener('click', () => {
if (playing) { setPlaying(false); return; }
if (cur >= stages.length - 1) show(0);
setPlaying(true);
timer = setTimeout(tick, 1200);
});
document.getElementById('next').addEventListener('click', () => { setPlaying(false); show(cur + 1); });
document.getElementById('prev').addEventListener('click', () => { setPlaying(false); show(cur - 1); });
document.getElementById('reset').addEventListener('click', () => { setPlaying(false); show(0); });
nodes.forEach((n, i) => n.addEventListener('click', () => { setPlaying(false); show(i); }));
window.addEventListener('keydown', e => {
if (e.target && ['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName)) return;
if (e.key === ' ') { e.preventDefault(); playBtn.click(); }
else if (e.key === 'ArrowRight') document.getElementById('next').click();
else if (e.key === 'ArrowLeft') document.getElementById('prev').click();
});
show(0, false);
})();
</script>
</body>
</html>`;
}
export async function renderFlow(jsonPath?: string): Promise<string> {
const file = jsonPath ?? (await pickLatest());
if (!file) throw new Error('No run JSON found in output/.');
const raw = await readFile(file, 'utf8');
const data = JSON.parse(raw);
const html = render(data, file);
const outPath = file.replace(/\.json$/, '.flow.html');
await writeFile(outPath, html, 'utf8');
return outPath;
}
const invokedDirectly = (() => {
try {
return import.meta.url === `file://${process.argv[1]}`;
} catch {
return false;
}
})();
if (invokedDirectly) {
const arg = process.argv[2];
renderFlow(arg)
.then(p => {
// eslint-disable-next-line no-console
console.log(`Wrote flow page: ${path.relative(process.cwd(), p)}`);
})
.catch(err => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
}