Files
gbrain/src/commands/eval-conversation-parser.ts
T
f702ec053b v0.41.16.0 feat: conversation parser cathedral + progressive-batch primitive (closes #1461) (#1510)
* v0.41.15.0 feat: conversation parser cathedral + progressive-batch primitive (closes #1461)

Replaces PR #1461's single-format Telegram regex with a 12-pattern
built-in registry covering iMessage/Slack, Telegram (×2), Discord
(×2), WhatsApp (×2 locales), Signal, Matrix/Element, IRC (×2), Teams.
Each pattern is hand-vetted from public format docs (signal-cli,
DiscordChatExporter, Telegram Desktop, WhatsApp export docs, Element
matrix-archive, irssi/weechat defaults); module-load validation runs
test_positive[] + test_negative[] for every pattern at startup so a
typo makes gbrain refuse to start.

PR #1461 contributor's BRACKET_TIME_RX + cleanSpeaker survive verbatim
as the `telegram-bracket` built-in pattern + DEFAULT_SPEAKER_CLEAN
export. All 33 of their test cases pass against the new orchestrator.

Three layers per page (orchestrator chooses):
  1. Built-in pattern registry (zero-cost, deterministic)
  2. User-declared simple_pattern via config (deferred to v0.42+)
  3. Opt-IN LLM polish + fallback (privacy-first; chat content goes
     to Anthropic only when user explicitly enables)

D18 priority scoring picks the highest-match-rate pattern across the
first 10 lines (not first-wins) so overlapping formats don't silently
mis-route. D5 multi_line per-pattern + D11 quick_reject prefix screen
+ D19 timezone_policy per-pattern complete the registry shape.

Companion: src/core/progressive-batch/ primitive (rule of three
satisfied across 12+ ad-hoc cost-prompt sites). Wintermute-inspired
ramp shape (trial 10 → 100 → 500 → full with verification at each
stage), productionized with verifier+policy injection (callers
describe HOW TO MEASURE SUCCESS, not WHEN TO WAIT FOR CTRL-C). D3
fail-closed budget gate: null tracker + null Policy.maxCostUsd →
abort_cost_cap reason='no_budget_safety_net'. D20 discriminated
Verifier union (output_count | idempotent_mutation | noop).
extract-conversation-facts is the one proven consumer in v0.41.15.0;
9-site retrofit deferred to v0.41.16.0+ per TODOS.md.

Codex outside-voice review absorbed 8 substantive findings:
  - Privacy posture (LLM polish/fallback flipped to opt-IN)
  - ReDoS theater (dropped arbitrary user regex; v0.42+ uses RE2)
  - LLM-inferred-regex persistence as silent-corruption machine
  - Pattern priority scoring across first 10 lines
  - Timezone policy on every PatternEntry
  - Verifier shape discriminated union
  - Behavior parity for sites that "jumped straight to full"
  - Real-corpus-redacted fixture gap (v0.42+ TODO)

CI gates:
  - bun run check:conversation-parser (13 fixtures, --no-llm, deterministic)
  - bun run check:fixture-privacy (banned-token grep)

Doctor surfaces 3 new checks: conversation_format_coverage,
progressive_batch_audit_health, conversation_parser_probe_health.

Tests: 198/198 across primitive + parser + LLM + nightly probe + eval
CLI + debug CLI + doctor checks + migration v97 round-trip + E2E
parser ↔ engine integration. Real bug caught + fixed during gap audit:
IdempotentMutationVerifier was comparing absolute mutated-count vs
per-stage expected (failed silently on stage 2+); now uses per-stage
delta semantics matching OutputCountVerifier.

Schema migration v97: conversation_parser_llm_cache table with
(content_sha256, model_id, call_shape) composite key. NO
inferred_patterns table (D17: silent-corruption machine).

Plan + 23 decisions + codex outside-voice absorption at
~/.claude/plans/system-instruction-you-are-working-cuddly-hollerith.md.

Co-Authored-By: garrytan-agents (PR #1461) <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(check-privacy): allowlist scripts/check-fixture-privacy.sh

The new sibling privacy guard literally names the banned tokens in its
BANNED_TOKENS array — same meta-exception that check-privacy.sh itself
gets. Without this allowlist entry, bun run verify rejects the file
post-merge because the banned name appears in the rule-definition script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: renumber v0.41.15.0 → v0.41.16.0 (queue drift)

Mechanical rename across all surfaces: VERSION, package.json,
CHANGELOG (header + body refs), CLAUDE.md, TODOS.md, src/core/
migrate.ts (migration v98 comment), all src/core/conversation-parser/*
and src/core/progressive-batch/* file headers, all test/ headers,
scripts/check-privacy.sh allowlist comment, llms-full.txt regenerated.

Audit clean: VERSION + package.json + CHANGELOG header all show
0.41.16.0. verify 24/24, touched tests 179/179.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents (PR #1461) <noreply@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:31:48 -07:00

181 lines
5.1 KiB
TypeScript

/**
* v0.41.16.0 — `gbrain eval conversation-parser <fixture.jsonl>` CLI verb.
*
* Per Layer 4a: fixture-corpus CI gate. Deterministic; runs in PR CI
* with `--no-llm` so built-in regex regressions block PRs WITHOUT
* spending API tokens.
*
* Exit codes (match eval-gate convention):
* 0 — PASS (all fixtures passed)
* 1 — FAIL (any fixture failed)
* 2 — USAGE (bad args, missing fixture, malformed JSONL)
*
* Stable JSON envelope under `--json`:
* {
* schema_version: 1,
* ...EvalReport
* }
*/
import { readFileSync, existsSync } from 'node:fs';
import {
parseFixtureJsonl,
scoreFixture,
aggregateScores,
type ConversationFixture,
type EvalReport,
} from '../core/conversation-parser/eval.ts';
interface CliArgs {
fixtures?: string;
minRecall: number;
noLlm: boolean;
json: boolean;
help: boolean;
}
function parseArgs(args: string[]): CliArgs {
const out: CliArgs = {
minRecall: 0.9,
noLlm: false,
json: false,
help: false,
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--fixtures') {
out.fixtures = args[++i];
} else if (a.startsWith('--fixtures=')) {
out.fixtures = a.slice('--fixtures='.length);
} else if (a === '--min-recall') {
out.minRecall = Number(args[++i]);
} else if (a.startsWith('--min-recall=')) {
out.minRecall = Number(a.slice('--min-recall='.length));
} else if (a === '--no-llm') {
out.noLlm = true;
} else if (a === '--json') {
out.json = true;
} else if (a === '--help' || a === '-h') {
out.help = true;
} else if (!out.fixtures && !a.startsWith('--')) {
// Positional fixture path (e.g. `gbrain eval conversation-parser path.jsonl`).
out.fixtures = a;
}
}
return out;
}
function printHelp(): void {
process.stdout.write(`Usage: gbrain eval conversation-parser [options] <fixture.jsonl>
Run the v0.41.16.0 conversation parser against a JSONL fixture
corpus and report per-fixture quality + an aggregate verdict.
Options:
--fixtures <path> Path to JSONL fixture file. Also positional.
--min-recall <float> Recall floor for positive fixtures. Default 0.9.
--no-llm Disable LLM polish + fallback (built-in regex only).
Use for CI gating to keep the run deterministic.
--json Emit stable JSON envelope on stdout.
--help, -h Print this help.
Exit codes:
0 PASS — every fixture met its criteria.
1 FAIL — one or more fixtures failed; failure list printed.
2 USAGE — bad args, missing fixture, malformed JSONL.
Fixture line shape (one JSON object per line):
{
"fixture_id": "string",
"pattern": "string | null (null = adversarial)",
"frontmatter": { "date": "YYYY-MM-DD", ... },
"body": "string",
"expected_messages": number,
"expected_participants": ["string", ...]
}
`);
}
/**
* Returns the exit code. CLI dispatcher process.exit's on the return.
*/
export async function runEvalConversationParser(
args: string[],
): Promise<number> {
const cli = parseArgs(args);
if (cli.help) {
printHelp();
return 0;
}
if (!cli.fixtures) {
process.stderr.write(
'[eval conversation-parser] USAGE: missing fixture path. See --help.\n',
);
return 2;
}
if (!existsSync(cli.fixtures)) {
process.stderr.write(
`[eval conversation-parser] USAGE: fixture file not found: ${cli.fixtures}\n`,
);
return 2;
}
let fixtures: ConversationFixture[];
try {
const content = readFileSync(cli.fixtures, 'utf8');
fixtures = parseFixtureJsonl(content);
} catch (err) {
process.stderr.write(
`[eval conversation-parser] USAGE: ${(err as Error).message}\n`,
);
return 2;
}
if (fixtures.length === 0) {
process.stderr.write(
'[eval conversation-parser] USAGE: fixture file has zero parseable lines.\n',
);
return 2;
}
const scores = fixtures.map((f) =>
scoreFixture(f, { minRecall: cli.minRecall, noLlm: cli.noLlm }),
);
const report: EvalReport = aggregateScores(scores);
if (cli.json) {
process.stdout.write(JSON.stringify(report) + '\n');
} else {
formatHumanReport(report);
}
return report.failed > 0 ? 1 : 0;
}
function formatHumanReport(report: EvalReport): void {
process.stdout.write(
`[eval conversation-parser] ${report.passed}/${report.total_fixtures} fixtures passed\n`,
);
process.stdout.write(
`[eval conversation-parser] recall_mean=${report.recall_mean.toFixed(3)} ` +
`participants_recall_mean=${report.participants_recall_mean.toFixed(3)}\n`,
);
process.stdout.write(
`[eval conversation-parser] pattern_coverage: ${Object.entries(report.pattern_coverage)
.map(([k, v]) => `${k}=${v}`)
.join(', ')}\n`,
);
if (report.failed > 0) {
process.stdout.write('\nFAILED FIXTURES:\n');
for (const s of report.fixtures) {
if (s.passed) continue;
process.stdout.write(
` - ${s.fixture_id} (expected=${s.expected_pattern}, got=${s.matched_pattern_id ?? 'no_match'})\n`,
);
for (const r of s.reasons) {
process.stdout.write(` • ${r}\n`);
}
}
}
}