diff --git a/tools/observer-retrofill-chain-ref.mjs b/tools/observer-retrofill-chain-ref.mjs new file mode 100644 index 00000000..3d6df8d2 --- /dev/null +++ b/tools/observer-retrofill-chain-ref.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * One-shot retrofill: add primary_rationale.chain_ref to existing v2 episodes + * in docs/observer/episodes-*.jsonl. Idempotent (skips lines that already have + * chain_ref), atomic per file (tmp + rename). Pure fs, no LLM. + * + * Usage: node tools/observer-retrofill-chain-ref.mjs [--dry-run] + */ +import { readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { loadChainMap, chainsFor } from './observer-chain-detector.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OBS_DIR = join(__dirname, '..', 'docs', 'observer'); + +/** Add chain_ref to a single parsed episode object (pure). Idempotent. */ +export function retrofillLine(ep, map) { + if (!ep || ep.schema_version !== 2 || !ep.primary_rationale) return ep; + if ('chain_ref' in ep.primary_rationale) return ep; // idempotent + ep.primary_rationale.chain_ref = chainsFor(ep.primary_rationale.node_chosen, map); + return ep; +} + +/** Process one JSONL file atomically (tmp + rename). Returns {changed, total}. */ +export function retrofillFile(path, map, { dryRun = false } = {}) { + const lines = readFileSync(path, 'utf8').split(/\r?\n/); + let changed = 0; + let total = 0; + const out = lines.map((line) => { + if (!line.trim()) return line; + total++; + const ep = JSON.parse(line); + const before = ep.primary_rationale && 'chain_ref' in ep.primary_rationale; + const next = retrofillLine(ep, map); + const after = next.primary_rationale && 'chain_ref' in next.primary_rationale; + if (!before && after) changed++; + return JSON.stringify(next); + }); + if (!dryRun && changed > 0) { + const tmp = `${path}.tmp`; + writeFileSync(tmp, out.join('\n'), 'utf8'); + renameSync(tmp, path); + } + return { changed, total }; +} + +function main() { + const dryRun = process.argv.includes('--dry-run'); + const map = loadChainMap(); + const files = readdirSync(OBS_DIR).filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f)); + for (const f of files) { + const { changed, total } = retrofillFile(join(OBS_DIR, f), map, { dryRun }); + console.log(`${dryRun ? '[dry-run] ' : ''}${f}: ${changed}/${total} lines get chain_ref`); + } +} + +if (process.argv[1]?.endsWith('observer-retrofill-chain-ref.mjs')) main(); diff --git a/tools/observer-retrofill-chain-ref.test.mjs b/tools/observer-retrofill-chain-ref.test.mjs new file mode 100644 index 00000000..9ba74107 --- /dev/null +++ b/tools/observer-retrofill-chain-ref.test.mjs @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { retrofillLine } from './observer-retrofill-chain-ref.mjs'; +import { loadChainMap } from './observer-chain-detector.mjs'; + +const map = loadChainMap(); + +describe('retrofillLine', () => { + it('adds chain_ref to a v2 episode with a known node', () => { + const ep = { schema_version: 2, primary_rationale: { node_chosen: 'billing-audit' } }; + const out = retrofillLine(ep, map); + expect(out.primary_rationale.chain_ref).toEqual(['L13']); + }); + + it('sets chain_ref null for a direct v2 episode', () => { + const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct' } }; + expect(retrofillLine(ep, map).primary_rationale.chain_ref).toBeNull(); + }); + + it('is idempotent — does not overwrite existing chain_ref', () => { + const ep = { schema_version: 2, primary_rationale: { node_chosen: 'direct', chain_ref: ['L1'] } }; + expect(retrofillLine(ep, map).primary_rationale.chain_ref).toEqual(['L1']); + }); + + it('skips v1 episodes (no schema_version 2)', () => { + const ep = { foo: 'bar' }; + expect(retrofillLine(ep, map)).toEqual({ foo: 'bar' }); + }); +});