mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +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>
175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
/**
|
|
* v0.41.16.0 — LLM polish unit tests.
|
|
*
|
|
* Pins:
|
|
* - applyPolish pure function: merge + drop + edits
|
|
* - Headroom guard skip path
|
|
* - Provider unavailable → input messages unchanged
|
|
* - Cache hit doesn't re-call
|
|
*/
|
|
|
|
import { describe, expect, test, beforeEach } from 'bun:test';
|
|
import { withEnv } from '../helpers/with-env.ts';
|
|
import {
|
|
runLlmPolish,
|
|
applyPolish,
|
|
} from '../../src/core/conversation-parser/llm-polish.ts';
|
|
import { _resetLlmCacheForTests } from '../../src/core/conversation-parser/llm-base.ts';
|
|
import { BudgetTracker } from '../../src/core/budget/budget-tracker.ts';
|
|
import { withBudgetTracker } from '../../src/core/ai/gateway.ts';
|
|
import type { MatchedMessage } from '../../src/core/conversation-parser/types.ts';
|
|
import { makeChatResult } from './helpers.ts';
|
|
|
|
beforeEach(() => {
|
|
_resetLlmCacheForTests();
|
|
});
|
|
|
|
const SAMPLE: MatchedMessage[] = [
|
|
{ speaker: 'Alice', timestamp: '2024-03-15T18:37:00Z', text: 'hello' },
|
|
{ speaker: 'Alice', timestamp: '2024-03-15T18:37:30Z', text: 'continuation' },
|
|
{ speaker: 'system', timestamp: '2024-03-15T18:38:00Z', text: 'Bob joined' },
|
|
{ speaker: 'Bob', timestamp: '2024-03-15T18:39:00Z', text: 'world (edited)' },
|
|
];
|
|
|
|
describe('applyPolish — pure function', () => {
|
|
test('merge two indices into one', () => {
|
|
const r = applyPolish(SAMPLE, {
|
|
merge_indices: [[0, 1]],
|
|
drop_indices: [],
|
|
edits: [],
|
|
});
|
|
expect(r.messages).toHaveLength(3);
|
|
expect(r.messages[0].text).toBe('hello\ncontinuation');
|
|
expect(r.delta.merged).toBe(1);
|
|
});
|
|
|
|
test('drop indices', () => {
|
|
const r = applyPolish(SAMPLE, {
|
|
merge_indices: [],
|
|
drop_indices: [2],
|
|
edits: [],
|
|
});
|
|
expect(r.messages).toHaveLength(3);
|
|
expect(r.messages.find((m) => m.text === 'Bob joined')).toBeUndefined();
|
|
expect(r.delta.dropped).toBe(1);
|
|
});
|
|
|
|
test('edit speaker and text', () => {
|
|
const r = applyPolish(SAMPLE, {
|
|
merge_indices: [],
|
|
drop_indices: [],
|
|
edits: [
|
|
{ index: 3, field: 'text', value: 'world' },
|
|
],
|
|
});
|
|
expect(r.messages[3].text).toBe('world');
|
|
expect(r.delta.edits).toBe(1);
|
|
});
|
|
|
|
test('combined: drop system msg, merge two, edit edited marker', () => {
|
|
const r = applyPolish(SAMPLE, {
|
|
merge_indices: [[0, 1]],
|
|
drop_indices: [2],
|
|
edits: [{ index: 3, field: 'text', value: 'world' }],
|
|
});
|
|
expect(r.messages).toHaveLength(2);
|
|
expect(r.messages[0].text).toBe('hello\ncontinuation');
|
|
expect(r.messages[1].text).toBe('world');
|
|
});
|
|
|
|
test('no-op: returns messages unchanged', () => {
|
|
const r = applyPolish(SAMPLE, {
|
|
merge_indices: [],
|
|
drop_indices: [],
|
|
edits: [],
|
|
});
|
|
expect(r.messages).toHaveLength(4);
|
|
expect(r.delta).toEqual({ merged: 0, dropped: 0, edits: 0 });
|
|
});
|
|
});
|
|
|
|
describe('runLlmPolish', () => {
|
|
test('happy path: LLM returns polish ops, applied', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
|
|
const r = await runLlmPolish({
|
|
modelStr: 'claude-haiku-4-5',
|
|
body: '...full body...',
|
|
messages: SAMPLE,
|
|
patternId: 'imessage-slack',
|
|
chatTransport: async () =>
|
|
makeChatResult(
|
|
JSON.stringify({
|
|
merge_indices: [[0, 1]],
|
|
drop_indices: [2],
|
|
edits: [{ index: 3, field: 'text', value: 'world' }],
|
|
}),
|
|
{ input_tokens: 100, output_tokens: 50 },
|
|
),
|
|
});
|
|
expect(r.skipped).toBeUndefined();
|
|
expect(r.messages).toHaveLength(2);
|
|
expect(r.delta.merged).toBe(1);
|
|
expect(r.delta.dropped).toBe(1);
|
|
expect(r.delta.edits).toBe(1);
|
|
});
|
|
});
|
|
|
|
test('headroom guard: skips when tracker within $0.10 of cap', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
|
|
const t = new BudgetTracker({ label: 'unit', maxCostUsd: 0.05 });
|
|
let calls = 0;
|
|
await withBudgetTracker(t, async () => {
|
|
const r = await runLlmPolish({
|
|
modelStr: 'claude-haiku-4-5',
|
|
body: 'b',
|
|
messages: SAMPLE,
|
|
patternId: 'imessage-slack',
|
|
chatTransport: async () => {
|
|
calls++;
|
|
return makeChatResult('{"merge_indices":[],"drop_indices":[],"edits":[]}', { input_tokens: 1, output_tokens: 1 });
|
|
},
|
|
});
|
|
expect(r.skipped).toBe('headroom');
|
|
expect(r.messages).toBe(SAMPLE); // unchanged input
|
|
});
|
|
expect(calls).toBe(0);
|
|
});
|
|
});
|
|
|
|
test('provider unavailable: returns input unchanged + skipped=provider', async () => {
|
|
await withEnv(
|
|
{ ANTHROPIC_API_KEY: undefined as unknown as string },
|
|
async () => {
|
|
const r = await runLlmPolish({
|
|
modelStr: 'claude-haiku-4-5',
|
|
body: 'b',
|
|
messages: SAMPLE,
|
|
patternId: 'imessage-slack',
|
|
chatTransport: async () => makeChatResult('{}', { input_tokens: 1, output_tokens: 1 }),
|
|
});
|
|
expect(r.skipped).toBe('provider');
|
|
expect(r.messages).toEqual(SAMPLE);
|
|
},
|
|
);
|
|
});
|
|
|
|
test('cache hit on same (body, patternId) pair', async () => {
|
|
await withEnv({ ANTHROPIC_API_KEY: 'sk-test' }, async () => {
|
|
let calls = 0;
|
|
const opts = {
|
|
modelStr: 'claude-haiku-4-5',
|
|
body: 'stable',
|
|
messages: SAMPLE,
|
|
patternId: 'imessage-slack',
|
|
chatTransport: async () => {
|
|
calls++;
|
|
return makeChatResult('{"merge_indices":[],"drop_indices":[],"edits":[]}', { input_tokens: 1, output_tokens: 1 });
|
|
},
|
|
};
|
|
await runLlmPolish(opts);
|
|
await runLlmPolish(opts);
|
|
expect(calls).toBe(1);
|
|
});
|
|
});
|
|
});
|