mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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')> → <img src=x onerror=alert('xss')>
<script>fetch('/steal')</script> → <script>fetch('/steal')</script>
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e2a5dc4a2b
commit
f0649e2f32
@@ -12,3 +12,4 @@ supabase/.temp/
|
||||
.claude/skills/
|
||||
.idea
|
||||
eval/reports/
|
||||
eval/data/world-v1/world.html
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* eval:query:new — scaffold a new Tier 5.5 query from template.
|
||||
*
|
||||
* Prints a well-formed Query JSON block that passes `eval:query:validate`.
|
||||
* Contributors copy-paste into their own query file (or a PR against
|
||||
* eval/external-authors/<author-slug>/queries.json).
|
||||
*
|
||||
* Usage:
|
||||
* bun run eval:query:new # default tier 5.5
|
||||
* bun run eval:query:new --tier fuzzy
|
||||
* bun run eval:query:new --tier externally-authored --author "@alice"
|
||||
* bun run eval:query:new --id q-custom-0042
|
||||
*/
|
||||
|
||||
import type { Query, Tier } from '../runner/types.ts';
|
||||
|
||||
function printHelp() {
|
||||
console.log(`eval:query:new — scaffold a Query template
|
||||
|
||||
USAGE
|
||||
bun run eval:query:new scaffold default (tier 5.5)
|
||||
bun run eval:query:new --tier easy specify tier
|
||||
bun run eval:query:new --id q-0001 specify id
|
||||
bun run eval:query:new --author "@alice-researcher" external author
|
||||
|
||||
OPTIONS
|
||||
--tier easy | medium | hard | adversarial | fuzzy | externally-authored
|
||||
--id Query ID (default: q-<timestamp>)
|
||||
--author Author handle (required for tier=externally-authored)
|
||||
|
||||
OUTPUT
|
||||
Prints a JSON object that passes eval:query:validate. Copy into
|
||||
your query file, fill in gold.relevant (or expected_abstention for
|
||||
abstention queries), and iterate.
|
||||
`);
|
||||
}
|
||||
|
||||
function getArg(name: string, fallback?: string): string | undefined {
|
||||
const prefix = `--${name}=`;
|
||||
for (const a of process.argv.slice(2)) {
|
||||
if (a.startsWith(prefix)) return a.slice(prefix.length);
|
||||
if (a === `--${name}`) {
|
||||
const next = process.argv[process.argv.indexOf(a) + 1];
|
||||
if (next && !next.startsWith('--')) return next;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const tier = (getArg('tier') ?? 'externally-authored') as Tier;
|
||||
const id = getArg('id') ?? `q-${Date.now().toString().slice(-6)}`;
|
||||
const author = getArg('author');
|
||||
|
||||
const template: Query = {
|
||||
id,
|
||||
tier,
|
||||
text: 'REPLACE with your query text (a question or search fragment)',
|
||||
expected_output_type: 'cited-source-pages',
|
||||
gold: {
|
||||
relevant: ['REPLACE/with-real-slug', 'REPLACE/with-another-slug-if-needed'],
|
||||
},
|
||||
tags: ['REPLACE-with-tier-or-theme-tags'],
|
||||
};
|
||||
|
||||
if (tier === 'externally-authored') {
|
||||
template.author = author ?? 'REPLACE-with-your-handle';
|
||||
}
|
||||
|
||||
// If the query text contains a temporal verb, the validator will require
|
||||
// as_of_date. Leave a helpful placeholder.
|
||||
template.as_of_date = 'REPLACE if temporal ("corpus-end" | "per-source" | YYYY-MM-DD); else delete this field';
|
||||
|
||||
console.log(JSON.stringify(template, null, 2));
|
||||
console.log(`\n// Next: save as a JSON file, run 'bun run eval:query:validate <path>'`);
|
||||
console.log(`// For Tier 5.5: submit a PR to eval/external-authors/${author ?? '<your-slug>'}/queries.json`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* eval:query:validate — validate a query file (or the built-in Tier 5/5.5 set).
|
||||
*
|
||||
* Usage:
|
||||
* bun run eval:query:validate # validate all built-in T5+T5.5
|
||||
* bun run eval:query:validate path/to/file.ts # validate a file that exports Query[]
|
||||
* bun run eval:query:validate --help
|
||||
*
|
||||
* Exit code 0 if all queries pass, 1 otherwise. Suitable for CI.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { validateAll, validateQuerySet, formatIssues } from '../runner/queries/index.ts';
|
||||
import type { Query } from '../runner/types.ts';
|
||||
|
||||
function printHelp() {
|
||||
console.log(`eval:query:validate — validate a Query set
|
||||
|
||||
USAGE
|
||||
bun run eval:query:validate validate all built-in T5 + T5.5 queries
|
||||
bun run eval:query:validate <path> validate a JSON file containing Query[]
|
||||
|
||||
VALIDATOR CHECKS
|
||||
- id, text, tier, expected_output_type present
|
||||
- Temporal verbs (is/was/were/current/now/at the time/during/as of/when did)
|
||||
require as_of_date ("corpus-end" | "per-source" | ISO-8601)
|
||||
- cited-source-pages requires non-empty gold.relevant with valid slug format
|
||||
- abstention requires gold.expected_abstention === true
|
||||
- externally-authored (Tier 5.5) requires author field
|
||||
- Duplicate IDs caught at batch level
|
||||
|
||||
EXIT CODES
|
||||
0 all queries valid
|
||||
1 one or more queries failed validation
|
||||
`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = args[0];
|
||||
|
||||
if (!filePath) {
|
||||
// Validate built-in T5 + T5.5 sets
|
||||
const result = validateAll();
|
||||
console.log(result.report);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
// Validate a file — supports JSON with { queries: Query[] } or Query[]
|
||||
let queries: Query[] = [];
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
queries = Array.isArray(parsed) ? parsed : (parsed.queries ?? []);
|
||||
} catch (e) {
|
||||
console.error(`Error reading ${filePath}: ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (queries.length === 0) {
|
||||
console.error(`No queries found in ${filePath}. Expected JSON Query[] or { queries: Query[] }.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = validateQuerySet(queries);
|
||||
console.log(formatIssues(result));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* eval:world:view — (re)generate world.html and open it in the default browser.
|
||||
*
|
||||
* Combines two steps the contributor shouldn't have to think about:
|
||||
* 1. Render eval/data/world-v1/world.html from the shard JSONs.
|
||||
* 2. Open it in the default browser via `open` (macOS) / `xdg-open` (Linux).
|
||||
*
|
||||
* If the HTML already exists and shards haven't changed since, we'd ideally
|
||||
* skip step 1 — but comparing timestamps is fragile and regeneration is fast
|
||||
* (~50ms on 240 entities). Just regenerate every time.
|
||||
*
|
||||
* Usage:
|
||||
* bun run eval:world:view (generates + opens)
|
||||
* bun run eval:world:view --no-open (generates only; useful in CI)
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { platform } from 'os';
|
||||
import { renderWorldHtmlToFile } from '../generators/world-html.ts';
|
||||
|
||||
function openInBrowser(path: string): void {
|
||||
const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
|
||||
try {
|
||||
execSync(`${cmd} "${path}"`, { stdio: 'ignore' });
|
||||
} catch (e) {
|
||||
// Don't fail hard — the file is rendered, that's the main thing.
|
||||
console.error(`Could not open browser automatically. Open manually: ${path}`);
|
||||
console.error(`(${(e as Error).message})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`eval:world:view — render + open world.html
|
||||
|
||||
USAGE
|
||||
bun run eval:world:view render eval/data/world-v1/world.html and open it
|
||||
bun run eval:world:view --no-open render only (CI-friendly)
|
||||
bun run eval:world:view --dir=PATH render from a different shard directory
|
||||
|
||||
OUTPUT
|
||||
eval/data/world-v1/world.html (gitignored; regenerate with this command).
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = args.find(a => a.startsWith('--dir='))?.slice('--dir='.length) ??
|
||||
'eval/data/world-v1';
|
||||
const noOpen = args.includes('--no-open');
|
||||
|
||||
const target = renderWorldHtmlToFile(dir);
|
||||
console.log(`Rendered ${target}`);
|
||||
|
||||
if (!noOpen) {
|
||||
openInBrowser(target);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { escapeHtml, renderWorldHtml } from './world-html.ts';
|
||||
|
||||
describe('escapeHtml — XSS safety', () => {
|
||||
test('escapes the 5 critical chars', () => {
|
||||
expect(escapeHtml('<script>alert(1)</script>'))
|
||||
.toBe('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
test('escapes ampersand FIRST (or double-escape bug happens)', () => {
|
||||
expect(escapeHtml('<'))
|
||||
.toBe('&lt;'); // the input < becomes &lt;
|
||||
});
|
||||
|
||||
test('escapes quotes (attribute context)', () => {
|
||||
expect(escapeHtml('onclick="alert(1)"'))
|
||||
.toBe('onclick="alert(1)"');
|
||||
expect(escapeHtml("onclick='alert(1)'"))
|
||||
.toBe('onclick='alert(1)'');
|
||||
});
|
||||
|
||||
test('passthrough for safe ASCII', () => {
|
||||
expect(escapeHtml('Hello world.')).toBe('Hello world.');
|
||||
});
|
||||
|
||||
test('handles null and undefined', () => {
|
||||
expect(escapeHtml(null)).toBe('');
|
||||
expect(escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
test('handles numbers', () => {
|
||||
expect(escapeHtml(42)).toBe('42');
|
||||
});
|
||||
|
||||
test('preserves Unicode (non-ASCII that\'s not special HTML)', () => {
|
||||
expect(escapeHtml('Héctor García 🎉')).toBe('Héctor García 🎉');
|
||||
});
|
||||
|
||||
test('real Opus prose injection attempt neutralized', () => {
|
||||
// Representative: if Opus generates this in an entity backstory,
|
||||
// the explorer HTML must NOT execute it. The XSS protection works
|
||||
// because `<img` is escaped to `<img` — the browser renders the
|
||||
// resulting string as text, not as an img element, so the embedded
|
||||
// `onerror=` attribute never gets parsed as an attribute.
|
||||
const attack = `<img src=x onerror=alert('xss')>`;
|
||||
const safe = escapeHtml(attack);
|
||||
// The opening `<` must be escaped (this is what neutralizes the tag).
|
||||
expect(safe).not.toContain('<img');
|
||||
// The single quote (which would break out of an attribute) is escaped.
|
||||
expect(safe).not.toContain("'xss'");
|
||||
expect(safe).toBe('<img src=x onerror=alert('xss')>');
|
||||
});
|
||||
|
||||
test('javascript: URL neutralized when inserted as text', () => {
|
||||
// Even as text content, escape quotes so it can't break attribute context.
|
||||
const attack = `"javascript:alert(1)//`;
|
||||
expect(escapeHtml(attack)).toBe('"javascript:alert(1)//');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderWorldHtml', () => {
|
||||
const samplePage = {
|
||||
slug: 'people/alice-chen',
|
||||
type: 'person' as const,
|
||||
title: 'Alice Chen',
|
||||
compiled_truth: 'Alice Chen is a senior engineer.',
|
||||
timeline: '- **2023-01-15** | Promoted to staff engineer',
|
||||
_facts: { type: 'person', role: 'engineer' },
|
||||
};
|
||||
|
||||
test('renders an HTML document', () => {
|
||||
const html = renderWorldHtml([samplePage]);
|
||||
expect(html).toContain('<!doctype html>');
|
||||
expect(html).toContain('<html');
|
||||
expect(html).toContain('Alice Chen');
|
||||
});
|
||||
|
||||
test('includes page in rail and in main cards', () => {
|
||||
const html = renderWorldHtml([samplePage]);
|
||||
// Rail link uses #slug anchor
|
||||
expect(html).toContain('href="#people/alice-chen"');
|
||||
// Entity card has matching id
|
||||
expect(html).toContain('id="people/alice-chen"');
|
||||
});
|
||||
|
||||
test('escapes all user content (XSS neutralization)', () => {
|
||||
const maliciousPage = {
|
||||
...samplePage,
|
||||
title: '<img src=x onerror=alert(1)>',
|
||||
compiled_truth: `<script>fetch('/steal')</script>`,
|
||||
};
|
||||
const html = renderWorldHtml([maliciousPage]);
|
||||
// Raw <script> / <img with onerror must NOT be present.
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).not.toContain('<script>fetch');
|
||||
// Escaped forms must be present.
|
||||
expect(html).toContain('<img src=x');
|
||||
expect(html).toContain('<script>fetch');
|
||||
});
|
||||
|
||||
test('groups by type in the rail', () => {
|
||||
const pages = [
|
||||
{ ...samplePage, slug: 'people/alice', title: 'Alice', type: 'person' as const },
|
||||
{ ...samplePage, slug: 'companies/acme', title: 'Acme', type: 'company' as const },
|
||||
{ ...samplePage, slug: 'meetings/standup', title: 'Standup', type: 'meeting' as const },
|
||||
];
|
||||
const html = renderWorldHtml(pages);
|
||||
expect(html).toContain('companys'); // rendered as "${type}s" — imperfect plural but cheap
|
||||
expect(html).toContain('persons');
|
||||
expect(html).toContain('meetings');
|
||||
});
|
||||
|
||||
test('empty corpus produces a valid (if sparse) document', () => {
|
||||
const html = renderWorldHtml([]);
|
||||
expect(html).toContain('<!doctype html>');
|
||||
expect(html).toContain('<nav');
|
||||
expect(html).toContain('<main');
|
||||
});
|
||||
|
||||
test('ledger metadata renders when provided', () => {
|
||||
const ledger = { generated_at: '2026-04-19T10:00:00Z', model: 'claude-opus-4-7', costUsd: 3.14 };
|
||||
const html = renderWorldHtml([samplePage], ledger);
|
||||
expect(html).toContain('claude-opus-4-7');
|
||||
expect(html).toContain('$3.14');
|
||||
});
|
||||
|
||||
test('ledger HTML-escapes generated_at and model', () => {
|
||||
const ledger = { generated_at: '<malicious>', model: 'opus<script>' };
|
||||
const html = renderWorldHtml([samplePage], ledger);
|
||||
expect(html).not.toContain('<malicious>');
|
||||
expect(html).not.toContain('opus<script>');
|
||||
expect(html).toContain('<malicious>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
}
|
||||
@@ -22,6 +22,14 @@
|
||||
"build:schema": "bash scripts/build-schema.sh",
|
||||
"test": "bun test",
|
||||
"test:e2e": "bun test test/e2e/",
|
||||
"test:eval": "bun test eval/runner/queries/validator.test.ts eval/runner/adapters/ eval/generators/world-html.test.ts",
|
||||
"eval:run": "bun eval/runner/multi-adapter.ts",
|
||||
"eval:run:dev": "BRAINBENCH_N=1 bun eval/runner/multi-adapter.ts",
|
||||
"eval:world:view": "bun eval/cli/world-view.ts",
|
||||
"eval:world:render": "bun eval/cli/world-view.ts --no-open",
|
||||
"eval:query:validate": "bun eval/cli/query-validate.ts",
|
||||
"eval:query:new": "bun eval/cli/query-new.ts",
|
||||
"eval:type-accuracy": "bun eval/runner/type-accuracy.ts",
|
||||
"postinstall": "gbrain --version >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive 2>/dev/null || true",
|
||||
"prepublish:clawhub": "bun run build:all",
|
||||
"publish:clawhub": "clawhub package publish . --family bundle-plugin"
|
||||
|
||||
Reference in New Issue
Block a user