mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
/**
|
|
* v0.41.16.0 — `gbrain eval conversation-parser` CLI behavior tests.
|
|
*
|
|
* Covers argv parsing, exit codes (0/1/2), --json envelope shape,
|
|
* --no-llm flag, --min-recall override, USAGE errors. Pure file I/O,
|
|
* no DB, no API keys.
|
|
*
|
|
* Critical because `bun run check:conversation-parser` is wired into
|
|
* `bun run verify` — a silent regression in exit-code handling would
|
|
* make every PR's CI green even when the parser broke.
|
|
*/
|
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { runEvalConversationParser } from '../src/commands/eval-conversation-parser.ts';
|
|
|
|
function tmpFixture(content: string): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'eval-cli-'));
|
|
const path = join(dir, 'fix.jsonl');
|
|
writeFileSync(path, content);
|
|
return path;
|
|
}
|
|
|
|
const PASS = `{"fixture_id":"p1","pattern":"imessage-slack","frontmatter":{"date":"2024-03-15"},"body":"**Alice** (2024-03-15 9:00 AM): hi","expected_messages":1,"expected_participants":["Alice"]}`;
|
|
const FAIL_PATTERN_MISMATCH = `{"fixture_id":"f1","pattern":"telegram-bracket","frontmatter":{"date":"2024-03-15"},"body":"**Alice** (2024-03-15 9:00 AM): hi","expected_messages":1,"expected_participants":["Alice"]}`;
|
|
|
|
describe('runEvalConversationParser — exit codes', () => {
|
|
test('exit 0 on all-pass fixture', async () => {
|
|
const path = tmpFixture(PASS);
|
|
const code = await runEvalConversationParser([path, '--no-llm']);
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
test('exit 1 on any failure', async () => {
|
|
const path = tmpFixture(FAIL_PATTERN_MISMATCH);
|
|
const code = await runEvalConversationParser([path, '--no-llm']);
|
|
expect(code).toBe(1);
|
|
});
|
|
|
|
test('exit 2 on missing fixture argument', async () => {
|
|
const code = await runEvalConversationParser(['--no-llm']);
|
|
expect(code).toBe(2);
|
|
});
|
|
|
|
test('exit 2 on nonexistent fixture path', async () => {
|
|
const code = await runEvalConversationParser([
|
|
'/nonexistent/path.jsonl',
|
|
'--no-llm',
|
|
]);
|
|
expect(code).toBe(2);
|
|
});
|
|
|
|
test('exit 2 on malformed JSONL', async () => {
|
|
const path = tmpFixture('NOT-JSON\n');
|
|
const code = await runEvalConversationParser([path]);
|
|
expect(code).toBe(2);
|
|
});
|
|
|
|
test('exit 2 on empty fixture file', async () => {
|
|
const path = tmpFixture('');
|
|
const code = await runEvalConversationParser([path]);
|
|
expect(code).toBe(2);
|
|
});
|
|
|
|
test('exit 0 on help', async () => {
|
|
const code = await runEvalConversationParser(['--help']);
|
|
expect(code).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('runEvalConversationParser — flag parsing', () => {
|
|
test('positional fixture path works without --fixtures', async () => {
|
|
const path = tmpFixture(PASS);
|
|
const code = await runEvalConversationParser([path, '--no-llm']);
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
test('--fixtures <path> form works', async () => {
|
|
const path = tmpFixture(PASS);
|
|
const code = await runEvalConversationParser([
|
|
'--fixtures',
|
|
path,
|
|
'--no-llm',
|
|
]);
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
test('--fixtures=<path> form works', async () => {
|
|
const path = tmpFixture(PASS);
|
|
const code = await runEvalConversationParser([
|
|
`--fixtures=${path}`,
|
|
'--no-llm',
|
|
]);
|
|
expect(code).toBe(0);
|
|
});
|
|
|
|
test('--min-recall override (lower floor lets near-misses pass)', async () => {
|
|
// A fixture where the parser only catches 0 of 1 expected messages.
|
|
// Default --min-recall 0.9 would fail this; --min-recall 0.0 passes.
|
|
const partial = `{"fixture_id":"pp","pattern":"telegram-bracket","frontmatter":{"date":"2024-03-15"},"body":"this is not a telegram line","expected_messages":1,"expected_participants":["Alice"]}`;
|
|
const path = tmpFixture(partial);
|
|
const defaultCode = await runEvalConversationParser([path, '--no-llm']);
|
|
expect(defaultCode).toBe(1); // expected pattern not matched → fail
|
|
// Lowering min-recall doesn't rescue a pattern mismatch — pattern
|
|
// identity is checked separately. This pins that contract.
|
|
const lowCode = await runEvalConversationParser([
|
|
path,
|
|
'--no-llm',
|
|
'--min-recall',
|
|
'0.0',
|
|
]);
|
|
expect(lowCode).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('runEvalConversationParser — --json envelope', () => {
|
|
test('emits stable schema_version=1 + recall + pattern_coverage', async () => {
|
|
const path = tmpFixture(PASS);
|
|
const origWrite = process.stdout.write.bind(process.stdout);
|
|
const captured: string[] = [];
|
|
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
captured.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
|
|
return true;
|
|
}) as typeof process.stdout.write;
|
|
try {
|
|
const code = await runEvalConversationParser([path, '--no-llm', '--json']);
|
|
expect(code).toBe(0);
|
|
} finally {
|
|
process.stdout.write = origWrite;
|
|
}
|
|
const stdout = captured.join('');
|
|
const json = JSON.parse(stdout.trim());
|
|
expect(json.schema_version).toBe(1);
|
|
expect(json.total_fixtures).toBe(1);
|
|
expect(json.passed).toBe(1);
|
|
expect(json.failed).toBe(0);
|
|
expect(json.recall_mean).toBe(1);
|
|
expect(json.participants_recall_mean).toBe(1);
|
|
expect(json.pattern_coverage).toEqual({ 'imessage-slack': 1 });
|
|
expect(json.fixtures).toHaveLength(1);
|
|
expect(json.failed_fixture_ids).toEqual([]);
|
|
});
|
|
|
|
test('--json on failure includes failed_fixture_ids', async () => {
|
|
const path = tmpFixture(FAIL_PATTERN_MISMATCH);
|
|
const origWrite = process.stdout.write.bind(process.stdout);
|
|
const captured: string[] = [];
|
|
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
captured.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
|
|
return true;
|
|
}) as typeof process.stdout.write;
|
|
try {
|
|
const code = await runEvalConversationParser([path, '--no-llm', '--json']);
|
|
expect(code).toBe(1);
|
|
} finally {
|
|
process.stdout.write = origWrite;
|
|
}
|
|
const json = JSON.parse(captured.join('').trim());
|
|
expect(json.failed_fixture_ids).toContain('f1');
|
|
});
|
|
});
|
|
|
|
describe('runEvalConversationParser — adversarial fixture', () => {
|
|
test('adversarial (pattern=null) with non-empty parse → fail', async () => {
|
|
// Adversarial fixture whose body LOOKS like iMessage; parser will
|
|
// match it; eval flags as adversarial false-positive.
|
|
const advFp = `{"fixture_id":"adv-fp","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"**Alice** (2024-03-15 9:00 AM): I should not parse","expected_messages":0,"expected_participants":[]}`;
|
|
const path = tmpFixture(advFp);
|
|
const code = await runEvalConversationParser([path, '--no-llm']);
|
|
expect(code).toBe(1);
|
|
});
|
|
|
|
test('adversarial with truly unparseable body → pass', async () => {
|
|
const advClean = `{"fixture_id":"adv-clean","pattern":null,"frontmatter":{"date":"2024-03-15"},"body":"This is just prose with no chat shape.","expected_messages":0,"expected_participants":[]}`;
|
|
const path = tmpFixture(advClean);
|
|
const code = await runEvalConversationParser([path, '--no-llm']);
|
|
expect(code).toBe(0);
|
|
});
|
|
});
|