BID · Console
Baseline · Intelligence · Decision
scripts/render-flow.ts 20,468 bytes · typescript
/**
 * 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/* ---------- 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);
    });
}