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>
164 lines
6.4 KiB
TypeScript
164 lines
6.4 KiB
TypeScript
/**
|
|
* v0.41.16.0 — E2E test for the conversation parser cathedral against
|
|
* a real PGLite brain.
|
|
*
|
|
* For each of the 12 built-in formats: seed a page through
|
|
* `importFromContent`, run `parseConversation` against the body, assert
|
|
* the parser identifies the correct pattern AND produces at least one
|
|
* message AND the message timestamp lands in the expected date range.
|
|
*
|
|
* Per CLAUDE.md test-isolation R3+R4: uses the canonical PGLite block.
|
|
* Hermetic (no DATABASE_URL needed); the in-memory PGLite is created
|
|
* once per file and reset between tests.
|
|
*
|
|
* This is the integration test that proves the parser ↔ engine
|
|
* interaction is correct for the dream cycle's
|
|
* `conversation_facts_backfill` phase.
|
|
*/
|
|
|
|
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
|
import {
|
|
parseConversation,
|
|
} from '../../src/core/conversation-parser/parse.ts';
|
|
import { BUILTIN_PATTERNS } from '../../src/core/conversation-parser/builtins.ts';
|
|
import { importFromContent } from '../../src/core/import-file.ts';
|
|
import type { Page } from '../../src/core/types.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
});
|
|
|
|
/**
|
|
* Build a conversation page body using the first test_positive sample
|
|
* from the pattern's registry entry. Multi-line patterns get a body
|
|
* line appended.
|
|
*/
|
|
function buildSampleBody(patternId: string): string {
|
|
const pattern = BUILTIN_PATTERNS.find((p) => p.id === patternId);
|
|
if (!pattern) throw new Error(`pattern ${patternId} not found`);
|
|
// Repeat the positive sample 3 times with slight variations to
|
|
// simulate a multi-message conversation.
|
|
const samples = pattern.test_positive.slice(0, 2);
|
|
if (pattern.multi_line && pattern.captures.text_group === 0) {
|
|
// For patterns where text comes from the next line, append a body
|
|
// line after each header.
|
|
return samples.flatMap((s, i) => [s, `body line ${i + 1}`]).join('\n');
|
|
}
|
|
return samples.join('\n');
|
|
}
|
|
|
|
describe('E2E: parser ↔ engine integration for every built-in', () => {
|
|
for (const pattern of BUILTIN_PATTERNS) {
|
|
test(`pattern=${pattern.id}: page imports + parser identifies pattern`, async () => {
|
|
const slug = `conversations/test/${pattern.id}-sample`;
|
|
const body = buildSampleBody(pattern.id);
|
|
const frontmatter: Record<string, unknown> = {
|
|
type: 'conversation',
|
|
date: '2024-03-15',
|
|
};
|
|
if (pattern.timezone_policy === 'utc_assumed_with_warn') {
|
|
// Some patterns warn without a timezone; provide one to avoid
|
|
// stderr noise in the test output.
|
|
frontmatter.timezone = 'America/Los_Angeles';
|
|
}
|
|
|
|
// Seed the page through the canonical import path.
|
|
const fullBody = `---\n${Object.entries(frontmatter)
|
|
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
.join('\n')}\n---\n\n${body}`;
|
|
await importFromContent(engine, slug, fullBody, {
|
|
sourceId: 'default',
|
|
noEmbed: true,
|
|
});
|
|
|
|
// Read the page back and parse it through the orchestrator
|
|
// (mirrors what extract-conversation-facts.ts does in production).
|
|
const page = (await engine.getPage(slug)) as Page;
|
|
expect(page).not.toBeNull();
|
|
|
|
const bodyToParse = `${page.compiled_truth ?? ''}\n${page.timeline ?? ''}`.trim();
|
|
const result = parseConversation(bodyToParse, { page });
|
|
|
|
expect(result.phase).toBe('regex_match');
|
|
expect(result.matched_pattern_id).toBe(pattern.id);
|
|
expect(result.messages.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// Every parsed message has a valid ISO timestamp.
|
|
for (const msg of result.messages) {
|
|
const ts = Date.parse(msg.timestamp);
|
|
expect(Number.isFinite(ts)).toBe(true);
|
|
// Pattern's timezone policy determines date range constraints.
|
|
// For inline-date patterns the date is in the line; for
|
|
// time-only patterns it's from frontmatter ('2024-03-15').
|
|
if (pattern.date_source === 'frontmatter') {
|
|
expect(msg.timestamp.slice(0, 10)).toBe('2024-03-15');
|
|
}
|
|
expect(msg.speaker.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('E2E: parser handles unparseable bodies cleanly', () => {
|
|
test('non-chat content returns no_match without crashing', async () => {
|
|
const slug = 'conversations/test/not-a-chat';
|
|
const fullBody = `---\ntype: conversation\ndate: 2024-03-15\n---\n\nThis is just prose. It has no chat structure at all. Just words that flow.`;
|
|
await importFromContent(engine, slug, fullBody, {
|
|
sourceId: 'default',
|
|
noEmbed: true,
|
|
});
|
|
const page = (await engine.getPage(slug)) as Page;
|
|
const bodyToParse = `${page.compiled_truth ?? ''}\n${page.timeline ?? ''}`.trim();
|
|
const result = parseConversation(bodyToParse, { page });
|
|
expect(result.phase).toBe('no_match');
|
|
expect(result.messages).toEqual([]);
|
|
});
|
|
|
|
test('empty body returns no_match', async () => {
|
|
const slug = 'conversations/test/empty';
|
|
const fullBody = `---\ntype: conversation\ndate: 2024-03-15\n---\n\n`;
|
|
await importFromContent(engine, slug, fullBody, {
|
|
sourceId: 'default',
|
|
noEmbed: true,
|
|
});
|
|
const page = (await engine.getPage(slug)) as Page;
|
|
const result = parseConversation(`${page.compiled_truth ?? ''}\n${page.timeline ?? ''}`.trim(), { page });
|
|
expect(result.phase).toBe('no_match');
|
|
expect(result.messages).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('E2E: D8 date derivation chain through Page', () => {
|
|
test('frontmatter.date wins over epoch default', async () => {
|
|
const slug = 'conversations/test/date-derive';
|
|
const body = '**[18:37] 👤 Alice:** hi';
|
|
const fullBody = `---\ntype: conversation\ndate: 2026-05-24\ntimezone: America/Los_Angeles\n---\n\n${body}`;
|
|
await importFromContent(engine, slug, fullBody, {
|
|
sourceId: 'default',
|
|
noEmbed: true,
|
|
});
|
|
const page = (await engine.getPage(slug)) as Page;
|
|
const result = parseConversation(
|
|
`${page.compiled_truth ?? ''}\n${page.timeline ?? ''}`.trim(),
|
|
{ page },
|
|
);
|
|
expect(result.matched_pattern_id).toBe('telegram-bracket');
|
|
expect(result.messages).toHaveLength(1);
|
|
expect(result.messages[0].timestamp).toBe('2026-05-24T18:37:00Z');
|
|
});
|
|
});
|