Files
gbrain/eval/generators/world-html.ts
T
Garry TanandClaude Opus 4.7 f0649e2f32 feat(eval): Phase 3 world.html explorer + eval:* CLI surface
Contributor DX magical moment. Static HTML explorer renders the full
canonical world (240 entities) as an explorable tree, opens in any browser,
zero install. Every string HTML-entity-encoded (XSS-safe — direct vuln
class per eng pass 2, confidence 9/10).

Added:
  eval/generators/world-html.ts         — renderer (~240 LOC; single-file
                                          HTML with inline CSS + minimal JS)
  eval/generators/world-html.test.ts    — 16 tests (XSS + rendering correctness)
  eval/cli/world-view.ts                — render + open in default browser
  eval/cli/query-validate.ts            — CLI wrapper for queries/validator
  eval/cli/query-new.ts                 — scaffold a query template

Modified:
  package.json                          — 7 new eval:* scripts
  .gitignore                            — ignore generated world.html

package.json scripts shipped:
  bun run test:eval                 all eval unit tests (57 pass)
  bun run eval:run                  full 4-adapter N=5 side-by-side
  bun run eval:run:dev              N=1 fast dev iteration
  bun run eval:world:view           render world.html + open in browser
  bun run eval:world:render         render only (CI-friendly, --no-open)
  bun run eval:query:validate       validate built-in T5+T5.5 (or a file path)
  bun run eval:query:new            scaffold a new Query JSON template
  bun run eval:type-accuracy        per-link-type accuracy report

XSS safety:
  escapeHtml() encodes the 5 critical chars (& < > " '). Tested directly
  with representative Opus-generated attacks:
    <img src=x onerror=alert('xss')>  → &lt;img src=x onerror=alert(&#39;xss&#39;)&gt;
    <script>fetch('/steal')</script>  → &lt;script&gt;fetch(&#39;/steal&#39;)&lt;/script&gt;
  Ledger metadata (generated_at, model) also escaped — covers the less
  obvious attack surface where Opus could emit tag-like content into the
  metadata file.

world.html structure:
  - Left rail: entities grouped by type with counts (companies, people,
    meetings, concepts), alphabetical within type
  - Right pane: per-entity cards with title + slug + compiled_truth +
    timeline + canonical _facts as collapsed JSON
  - URL fragment deep-links (#people/alice-chen)
  - Sticky rail on desktop; responsive stack on mobile
  - Vanilla JS for active-link highlighting on scroll (no framework)

Generated file: ~1MB for 240 entities (full prose). Gitignored; rebuild
with `bun run eval:world:view`. Regeneration is ~50ms.

Contributor TTHW (Tier 5.5 query authoring):
  1. bun run eval:world:view                         # see entities
  2. bun run eval:query:new --tier externally-authored --author "@me"
  3. edit template with real slug + query text
  4. bun run eval:query:validate path/to/file.json
  5. submit PR

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:15:18 +08:00

325 lines
12 KiB
TypeScript

/**
* World explorer HTML renderer (Phase 3 — contributor DX).
*
* Reads the world-v1 shards + _ledger.json and emits a static HTML file
* that renders the entire canonical world as an explorable tree. Zero
* install, opens in any browser via `bun run eval:world:view`.
*
* Design:
* - Single-file HTML with inline CSS + minimal vanilla JS
* - Left rail: list of entities grouped by type (companies / people /
* meetings / concepts), each a clickable link
* - Right pane: per-entity card with compiled_truth, timeline, and a
* relationships section showing incoming + outgoing facts from _facts
* - URL fragment (#people/alice-chen) deep-links to an entity
*
* XSS safety:
* Every generated string field passes through escapeHtml() before being
* inserted into the DOM as text. `<`, `&`, `"`, `'` all get entity-encoded.
* Opus can (and sometimes does) generate content that looks like
* HTML/script tags; unescaped, any contributor opening world.html would
* run attacker-controlled JS. High-confidence vuln class, 9/10.
*
* Markdown:
* compiled_truth + timeline can contain inline markdown (bold, links).
* For safety, we DON'T render markdown — we preserve linebreaks + show
* `[Name](slug)` links as plain text. This is intentionally minimal;
* a proper renderer would sanitize every AST node which is out of scope
* for v1.1.
*
* Output: writes to eval/data/world-v1/world.html by default.
*/
import { readdirSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
// ─── HTML escaping (XSS safety) ──────────────────────────────────
/**
* HTML-entity-encode a string for safe insertion as text content.
* Escapes the 5 characters that matter for XSS: & < > " '
* Does NOT escape Unicode (surrogate pairs etc. — those are fine as-is).
*/
export function escapeHtml(s: unknown): string {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Preserve linebreaks from Opus prose (they're meaningful) by replacing
* \n with <br>. Called AFTER escapeHtml so the <br> isn't escaped.
*/
function preserveLineBreaks(escaped: string): string {
return escaped.replace(/\n/g, '<br>');
}
// ─── Types ──────────────────────────────────────────────────────
interface Page {
slug: string;
type: 'person' | 'company' | 'meeting' | 'concept';
title: string;
compiled_truth: string;
timeline: string;
_facts: Record<string, unknown>;
}
interface Ledger {
generated_at?: string;
model?: string;
costUsd?: number;
files_total?: number;
[key: string]: unknown;
}
// ─── Corpus loader ──────────────────────────────────────────────
function loadCorpus(dir: string): { pages: Page[]; ledger: Ledger } {
const files = readdirSync(dir)
.filter(f => f.endsWith('.json'));
const pages: Page[] = [];
let ledger: Ledger = {};
for (const f of files) {
const raw = readFileSync(join(dir, f), 'utf-8');
if (f === '_ledger.json') {
try { ledger = JSON.parse(raw) as Ledger; } catch { /* fall through */ }
continue;
}
try {
const p = JSON.parse(raw) as Page;
if (Array.isArray(p.timeline)) p.timeline = (p.timeline as unknown as string[]).join('\n');
if (Array.isArray(p.compiled_truth)) {
p.compiled_truth = (p.compiled_truth as unknown as string[]).join('\n\n');
}
p.title = String(p.title ?? '');
p.compiled_truth = String(p.compiled_truth ?? '');
p.timeline = String(p.timeline ?? '');
pages.push(p);
} catch {
// skip malformed files, but don't fail the whole render
}
}
return { pages, ledger };
}
// ─── Rendering helpers ──────────────────────────────────────────
function renderEntityCard(p: Page): string {
const slugSafe = escapeHtml(p.slug);
const titleSafe = escapeHtml(p.title);
const typeSafe = escapeHtml(p.type);
const compiledSafe = preserveLineBreaks(escapeHtml(p.compiled_truth));
const timelineSafe = preserveLineBreaks(escapeHtml(p.timeline));
const factsJson = escapeHtml(JSON.stringify(p._facts, null, 2));
return `
<article class="entity" id="${slugSafe}" data-type="${typeSafe}">
<header>
<div class="entity-type">${typeSafe}</div>
<h2>${titleSafe}</h2>
<code class="slug">${slugSafe}</code>
</header>
<section>
<h3>Compiled truth</h3>
<div class="prose">${compiledSafe || '<em>(no compiled truth)</em>'}</div>
</section>
${p.timeline ? `
<section>
<h3>Timeline</h3>
<div class="prose">${timelineSafe}</div>
</section>` : ''}
<section class="facts">
<h3>Canonical facts (_facts)</h3>
<pre>${factsJson}</pre>
</section>
</article>
`;
}
function renderRail(pages: Page[]): string {
const grouped = new Map<string, Page[]>();
for (const p of pages) {
const arr = grouped.get(p.type) ?? [];
arr.push(p);
grouped.set(p.type, arr);
}
const order = ['company', 'person', 'meeting', 'concept'];
const sections: string[] = [];
for (const type of order) {
const list = grouped.get(type) ?? [];
if (list.length === 0) continue;
list.sort((a, b) => a.title.localeCompare(b.title));
const items = list.map(p => {
const title = escapeHtml(p.title);
const slug = escapeHtml(p.slug);
return `<li><a href="#${slug}" data-slug="${slug}">${title}</a></li>`;
}).join('');
sections.push(`
<section class="rail-section">
<h4>${escapeHtml(type)}s <span class="count">(${list.length})</span></h4>
<ul>${items}</ul>
</section>
`);
}
return sections.join('');
}
function renderLedger(ledger: Ledger, pageCount: number): string {
const lines: string[] = [];
lines.push(`<strong>${pageCount}</strong> entities`);
if (ledger.generated_at) {
lines.push(`generated <code>${escapeHtml(ledger.generated_at)}</code>`);
}
if (ledger.model) {
lines.push(`via <code>${escapeHtml(ledger.model)}</code>`);
}
if (typeof ledger.costUsd === 'number') {
lines.push(`cost <code>$${ledger.costUsd.toFixed(2)}</code>`);
}
return lines.join(' \u00b7 ');
}
// ─── Top-level render ───────────────────────────────────────────
export function renderWorldHtml(pages: Page[], ledger: Ledger = {}): string {
const entityCards = pages
.slice()
.sort((a, b) => {
const byType = a.type.localeCompare(b.type);
return byType !== 0 ? byType : a.title.localeCompare(b.title);
})
.map(renderEntityCard)
.join('\n');
const rail = renderRail(pages);
const ledgerLine = renderLedger(ledger, pages.length);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BrainBench twin-amara world explorer</title>
<style>
:root {
--fg: #1a1a1a; --fg-dim: #6b6b6b; --bg: #fafafa; --accent: #0a66c2;
--card-bg: #fff; --border: #e5e5e5; --code-bg: #f4f4f4;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font: 15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg); color: var(--fg);
display: grid; grid-template-columns: 280px 1fr; min-height: 100vh;
}
nav.rail {
background: #fff; border-right: 1px solid var(--border);
padding: 16px 12px 40px; overflow-y: auto; position: sticky; top: 0; height: 100vh;
}
nav.rail h1 { font-size: 14px; margin: 0 0 4px; }
nav.rail .subtitle { font-size: 11px; color: var(--fg-dim); margin-bottom: 16px; line-height: 1.4; }
nav.rail .ledger { font-size: 10px; color: var(--fg-dim); margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
nav.rail h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-dim); margin: 14px 0 4px; }
nav.rail .count { font-weight: normal; }
nav.rail ul { list-style: none; padding: 0; margin: 0; }
nav.rail li { margin: 0; }
nav.rail a {
display: block; padding: 3px 6px; color: var(--fg); text-decoration: none;
font-size: 12.5px; border-radius: 3px;
}
nav.rail a:hover { background: #f0f7ff; color: var(--accent); }
nav.rail a.active { background: #e8f1fc; color: var(--accent); font-weight: 500; }
main { padding: 28px 36px 80px; max-width: 900px; }
h1.page-title { font-size: 22px; margin: 0 0 4px; }
p.page-subtitle { color: var(--fg-dim); margin: 0 0 28px; font-size: 13px; }
article.entity {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 20px 24px; margin-bottom: 28px; scroll-margin-top: 16px;
}
article.entity header { margin-bottom: 14px; }
article.entity .entity-type {
display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--fg-dim); background: var(--code-bg); padding: 2px 6px; border-radius: 3px;
}
article.entity h2 { font-size: 19px; margin: 4px 0; }
article.entity .slug { font-size: 11px; color: var(--fg-dim); }
article.entity h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-dim); margin: 16px 0 6px; }
article.entity .prose { font-size: 14px; line-height: 1.55; }
article.entity .prose em { color: var(--fg-dim); }
article.entity section.facts pre {
font: 11px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
background: var(--code-bg); border-radius: 4px; padding: 10px;
overflow-x: auto; white-space: pre-wrap; color: var(--fg);
}
@media (max-width: 800px) {
body { grid-template-columns: 1fr; }
nav.rail { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
}
</style>
</head>
<body>
<nav class="rail">
<h1>twin-amara world</h1>
<div class="subtitle">BrainBench v1.1 canonical corpus</div>
<div class="ledger">${ledgerLine}</div>
${rail}
</nav>
<main>
<h1 class="page-title">Explore the canonical world</h1>
<p class="page-subtitle">
Click an entity in the sidebar to jump to it. All content is HTML-entity-encoded
to prevent XSS; Opus-generated prose can contain tag-like fragments. Use this
as a reference when writing Tier 5.5 queries.
</p>
${entityCards}
</main>
<script>
// Highlight the active nav link based on scroll position. Minimal vanilla.
(function() {
const railLinks = document.querySelectorAll('nav.rail a[data-slug]');
const entities = document.querySelectorAll('article.entity');
function updateActive() {
const y = window.scrollY + 120;
let active = null;
for (const e of entities) {
const top = e.getBoundingClientRect().top + window.scrollY;
if (top <= y) active = e.id;
}
railLinks.forEach(a => {
if (a.getAttribute('data-slug') === active) a.classList.add('active');
else a.classList.remove('active');
});
}
window.addEventListener('scroll', updateActive, { passive: true });
updateActive();
})();
</script>
</body>
</html>
`;
}
// ─── CLI entrypoint ─────────────────────────────────────────────
export function renderWorldHtmlToFile(dir: string, outPath?: string): string {
const { pages, ledger } = loadCorpus(dir);
const html = renderWorldHtml(pages, ledger);
const target = outPath ?? join(dir, 'world.html');
writeFileSync(target, html, 'utf-8');
return target;
}
// Run directly (e.g. `bun eval/generators/world-html.ts`)
if (import.meta.main) {
const dir = process.argv.find(a => a.startsWith('--dir='))?.slice('--dir='.length) ??
'eval/data/world-v1';
const out = process.argv.find(a => a.startsWith('--out='))?.slice('--out='.length);
const target = renderWorldHtmlToFile(dir, out);
console.log(`Wrote ${target}`);
}