mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
LLM-based take extraction from markdown prose. Walks pages updated since
last cycle, sends each page's body to a tuned extractor, writes the
extracted gradeable claims to the take_proposals queue. User accepts /
rejects via `gbrain takes propose --review` (lands in Lane C).
Cycle wiring:
lint → backlinks → sync → synthesize → extract → extract_facts →
resolve_symbol_edges → patterns → recompute_emotional_weight →
consolidate → propose_takes (NEW) → grade_takes (NEW; T4) →
calibration_profile (NEW; T6) → embed → orphans → purge
CyclePhase enum extended with 3 new entries; ALL_PHASES + NEEDS_LOCK_PHASES
updated. All three new phases acquire the cycle lock (writes to
take_proposals / take_grade_cache / calibration_profiles).
Idempotency contract:
The (source_id, page_slug, content_hash, prompt_version) composite unique
index on take_proposals means an unchanged page never re-spends LLM
tokens. Bumping PROPOSE_TAKES_PROMPT_VERSION cleanly invalidates the
cache so a tuned prompt re-runs proposals on every page. Mirrors the
v0.23 dream_verdicts pattern.
F2 fence dedup:
The phase reads the page's existing `<!-- gbrain:takes:begin -->` fence
(when present) and passes the canonical take rows to the extractor as
"things you have already captured." Prevents duplicate proposals when
prose is appended to a page that already has takes. Records the fence
rows the LLM was told to dedupe against on the take_proposals row for
audit (dedup_against_fence_rows JSONB).
Auto-resolve posture:
propose_takes only WRITES proposals to the queue. Nothing in this phase
mutates the canonical takes table. Operator opt-in via the queue review
CLI (Lane C) is the only path from queue to canonical fence (D17).
Prompt tuning status (v0.36.0.0 ship state):
The default extractor prompt is annotated `v0.36.0.0-stub`. The real
tuned prompt arrives via T19 synthetic corpus build (50 anonymized
pages, 3-model parallel extraction, user reviews disagreement set,
F1 ≥ 0.85 on training corpus + F1 ≥ 0.8 on ground-truth holdout).
Until T19 lands, propose_takes runs but produces best-effort candidates
the user reviews manually.
Architecture:
ProposeTakesPhase extends BaseCyclePhase (T2). Inherits source-scope
threading via scope(), budget metering via this.checkBudget(), error
envelope wrapping. budgetUsdKey: cycle.propose_takes.budget_usd
(default $5/cycle). Budget exhaustion mid-page returns status='warn'
with details.budget_exhausted=true — clean partial-completion semantics.
Test seam: opts.extractor injection so the phase can run hermetically
without touching the gateway. defaultExtractor (production path) calls
gateway.chat with the EXTRACT_TAKES_PROMPT and parses the JSON array
output via parseExtractorOutput.
parseExtractorOutput defends against common LLM output sins: markdown
code fence wrapping, leading prose, single-object instead of array,
unknown kind values, weight out of [0,1], rows missing claim_text or
exceeding 500 chars.
Tests: 25 cases in test/propose-takes.test.ts cover the 4 pure helpers
(parseExtractorOutput, contentHash, hasCompleteFence,
extractExistingTakesForDedup) + 7 phase integration scenarios (happy path,
cache hit, fence dedup, extractor failure, empty pages, skipPagesWithFence,
proposal_run_id stability).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
/**
|
|
* v0.36.0.0 (T3) — propose_takes phase unit tests.
|
|
*
|
|
* Pure structural tests against a mock BrainEngine + injected extractor.
|
|
* No real LLM gateway, no PGLite — the phase's contract is exercised through
|
|
* the public surface and the engine's executeRaw/listPages stubs.
|
|
*
|
|
* Tests cover:
|
|
* - happy path: extracts proposals, writes via executeRaw with idempotency clause
|
|
* - cache hit path: skip pages already in take_proposals (F2 idempotency)
|
|
* - fence dedup: existing fence rows pass through to extractor as context
|
|
* - budget exhaustion mid-page: phase aborts cleanly with warn status
|
|
* - extractor parse failures: warning logged, phase continues
|
|
* - parseExtractorOutput unit tests for the raw JSON parser
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
runPhaseProposeTakes,
|
|
parseExtractorOutput,
|
|
contentHash,
|
|
hasCompleteFence,
|
|
extractExistingTakesForDedup,
|
|
PROPOSE_TAKES_PROMPT_VERSION,
|
|
type ProposeTakesExtractor,
|
|
type ProposedTake,
|
|
} from '../src/core/cycle/propose-takes.ts';
|
|
import type { OperationContext } from '../src/core/operations.ts';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
import type { Page } from '../src/core/types.ts';
|
|
|
|
// ─── Mock engine ────────────────────────────────────────────────────
|
|
|
|
interface CapturedSql {
|
|
sql: string;
|
|
params: unknown[];
|
|
}
|
|
|
|
function buildMockEngine(opts: {
|
|
pages: Page[];
|
|
existingProposals?: Set<string>; // composite-key strings already in take_proposals
|
|
}): { engine: BrainEngine; captured: CapturedSql[] } {
|
|
const captured: CapturedSql[] = [];
|
|
const existing = opts.existingProposals ?? new Set<string>();
|
|
|
|
const engine = {
|
|
kind: 'pglite',
|
|
async listPages() {
|
|
return opts.pages;
|
|
},
|
|
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
captured.push({ sql, params: params ?? [] });
|
|
// SELECT idempotency check
|
|
if (sql.includes('SELECT id FROM take_proposals')) {
|
|
const [sourceId, slug, ch, pv] = params ?? [];
|
|
const key = `${sourceId}|${slug}|${ch}|${pv}`;
|
|
if (existing.has(key)) return [{ id: 1 } as unknown as T];
|
|
return [];
|
|
}
|
|
// INSERT — return nothing
|
|
return [];
|
|
},
|
|
} as unknown as BrainEngine;
|
|
|
|
return { engine, captured };
|
|
}
|
|
|
|
function buildPage(opts: { slug: string; body: string; sourceId?: string }): Page {
|
|
return {
|
|
id: 1,
|
|
slug: opts.slug,
|
|
type: 'analysis',
|
|
title: opts.slug,
|
|
compiled_truth: opts.body,
|
|
timeline: '',
|
|
frontmatter: {},
|
|
source_id: opts.sourceId ?? 'default',
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
} as Page;
|
|
}
|
|
|
|
function buildCtx(engine: BrainEngine): OperationContext {
|
|
return {
|
|
engine,
|
|
config: {} as never,
|
|
logger: { info() {}, warn() {}, error() {} } as never,
|
|
dryRun: false,
|
|
remote: false,
|
|
sourceId: 'default',
|
|
};
|
|
}
|
|
|
|
// ─── parseExtractorOutput ───────────────────────────────────────────
|
|
|
|
describe('parseExtractorOutput', () => {
|
|
test('parses a clean JSON array', () => {
|
|
const raw = '[{"claim_text":"Cities send messages","kind":"take","holder":"brain","weight":0.65}]';
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.claim_text).toBe('Cities send messages');
|
|
expect(out[0]!.kind).toBe('take');
|
|
expect(out[0]!.weight).toBe(0.65);
|
|
});
|
|
|
|
test('strips markdown code fence wrapping', () => {
|
|
const raw = '```json\n[{"claim_text":"X","kind":"bet","holder":"world","weight":0.8}]\n```';
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out).toHaveLength(1);
|
|
});
|
|
|
|
test('accepts a single object as a one-element array', () => {
|
|
const raw = '{"claim_text":"Y","kind":"hunch","holder":"brain","weight":0.4}';
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.kind).toBe('hunch');
|
|
});
|
|
|
|
test('skips leading prose before the JSON', () => {
|
|
const raw = 'Here are the takes:\n\n[{"claim_text":"Z","kind":"take","holder":"brain","weight":0.5}]';
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out).toHaveLength(1);
|
|
});
|
|
|
|
test('returns [] on empty input', () => {
|
|
expect(parseExtractorOutput('')).toEqual([]);
|
|
expect(parseExtractorOutput(' ')).toEqual([]);
|
|
});
|
|
|
|
test('returns [] on malformed JSON without throwing', () => {
|
|
expect(parseExtractorOutput('[not valid json')).toEqual([]);
|
|
expect(parseExtractorOutput('completely unrelated prose')).toEqual([]);
|
|
});
|
|
|
|
test('drops rows without claim_text and rows over 500 chars', () => {
|
|
const longClaim = 'x'.repeat(600);
|
|
const raw = JSON.stringify([
|
|
{ kind: 'take', holder: 'brain', weight: 0.5 }, // no claim_text
|
|
{ claim_text: longClaim, kind: 'take', holder: 'brain', weight: 0.5 },
|
|
{ claim_text: 'valid', kind: 'take', holder: 'brain', weight: 0.5 },
|
|
]);
|
|
expect(parseExtractorOutput(raw)).toHaveLength(1);
|
|
});
|
|
|
|
test('coerces unknown kind to "take" and clamps weight to [0,1]', () => {
|
|
const raw = JSON.stringify([
|
|
{ claim_text: 'a', kind: 'unknown_kind', holder: 'brain', weight: 2.5 },
|
|
{ claim_text: 'b', kind: 'take', holder: 'brain', weight: -0.5 },
|
|
]);
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out[0]!.kind).toBe('take');
|
|
expect(out[0]!.weight).toBe(1);
|
|
expect(out[1]!.weight).toBe(0);
|
|
});
|
|
|
|
test('preserves optional domain field', () => {
|
|
const raw = '[{"claim_text":"X","kind":"take","holder":"brain","weight":0.5,"domain":"macro"}]';
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out[0]!.domain).toBe('macro');
|
|
});
|
|
});
|
|
|
|
// ─── contentHash ────────────────────────────────────────────────────
|
|
|
|
describe('contentHash', () => {
|
|
test('produces deterministic SHA-256 hex', () => {
|
|
const h1 = contentHash('hello world');
|
|
const h2 = contentHash('hello world');
|
|
expect(h1).toBe(h2);
|
|
expect(h1).toHaveLength(64);
|
|
expect(h1).toMatch(/^[0-9a-f]+$/);
|
|
});
|
|
|
|
test('different input produces different hash', () => {
|
|
expect(contentHash('a')).not.toBe(contentHash('b'));
|
|
});
|
|
});
|
|
|
|
// ─── hasCompleteFence ───────────────────────────────────────────────
|
|
|
|
describe('hasCompleteFence', () => {
|
|
test('detects a well-formed fence', () => {
|
|
const body = `# Page
|
|
|
|
<!-- gbrain:takes:begin -->
|
|
| # | claim | kind | who | weight | since | source |
|
|
|---|-------|------|-----|--------|-------|--------|
|
|
| 1 | X | take | brain | 0.5 | 2026-01 | |
|
|
<!-- gbrain:takes:end -->
|
|
|
|
prose continues
|
|
`;
|
|
expect(hasCompleteFence(body)).toBe(true);
|
|
});
|
|
|
|
test('returns false when fence is incomplete (begin only)', () => {
|
|
expect(hasCompleteFence('<!-- gbrain:takes:begin -->\n| #')).toBe(false);
|
|
});
|
|
|
|
test('returns false when no fence at all', () => {
|
|
expect(hasCompleteFence('just some prose')).toBe(false);
|
|
});
|
|
|
|
test('detects fence with triple-dash variant', () => {
|
|
expect(hasCompleteFence('<!--- gbrain:takes:begin -->\n| # |\n<!--- gbrain:takes:end -->')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── extractExistingTakesForDedup ───────────────────────────────────
|
|
|
|
describe('extractExistingTakesForDedup', () => {
|
|
test('returns [] when no fence present', () => {
|
|
expect(extractExistingTakesForDedup('plain prose')).toEqual([]);
|
|
});
|
|
|
|
test('parses active rows from a well-formed fence', () => {
|
|
const body = `<!-- gbrain:takes:begin -->
|
|
| # | claim | kind | who | weight | since | source |
|
|
|---|-------|------|-----|--------|-------|--------|
|
|
| 1 | Cities send messages | take | brain | 0.65 | 2026-01 | essay |
|
|
| 2 | Y will happen | bet | garry | 0.8 | 2026-01 | |
|
|
<!-- gbrain:takes:end -->`;
|
|
const out = extractExistingTakesForDedup(body);
|
|
expect(out).toHaveLength(2);
|
|
expect(out[0]!.claim).toBe('Cities send messages');
|
|
expect(out[0]!.kind).toBe('take');
|
|
expect(out[1]!.weight).toBe(0.8);
|
|
});
|
|
|
|
test('skips strikethrough rows', () => {
|
|
const body = `<!-- gbrain:takes:begin -->
|
|
| # | claim | kind | who | weight |
|
|
|---|-------|------|-----|--------|
|
|
| 1 | ~~stale claim~~ | take | brain | 0.5 |
|
|
| 2 | active claim | take | brain | 0.5 |
|
|
<!-- gbrain:takes:end -->`;
|
|
const out = extractExistingTakesForDedup(body);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.claim).toBe('active claim');
|
|
});
|
|
});
|
|
|
|
// ─── Phase integration ──────────────────────────────────────────────
|
|
|
|
describe('runPhaseProposeTakes — phase integration', () => {
|
|
test('happy path: scans pages, extracts proposals, writes via INSERT', async () => {
|
|
const pages = [buildPage({ slug: 'wiki/concepts/network-effects', body: 'Marketplaces with cold-start liquidity always win.' })];
|
|
const { engine, captured } = buildMockEngine({ pages });
|
|
const extractor: ProposeTakesExtractor = async () => [
|
|
{ claim_text: 'Marketplaces with cold-start liquidity win', kind: 'bet', holder: 'brain', weight: 0.7, domain: 'market' },
|
|
];
|
|
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
|
|
expect(result.status).toBe('ok');
|
|
const details = result.details as Record<string, unknown>;
|
|
expect(details.pages_scanned).toBe(1);
|
|
expect(details.cache_misses).toBe(1);
|
|
expect(details.cache_hits).toBe(0);
|
|
expect(details.proposals_inserted).toBe(1);
|
|
|
|
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
|
|
expect(inserts).toHaveLength(1);
|
|
expect(inserts[0]!.params[5]).toBe('Marketplaces with cold-start liquidity win'); // claim_text
|
|
expect(inserts[0]!.params[6]).toBe('bet'); // kind
|
|
expect(inserts[0]!.params[9]).toBe('market'); // domain
|
|
});
|
|
|
|
test('cache hit: page already in take_proposals is skipped', async () => {
|
|
const body = 'A page that was already processed.';
|
|
const pages = [buildPage({ slug: 'wiki/old-page', body })];
|
|
const ch = contentHash(body);
|
|
const existing = new Set([`default|wiki/old-page|${ch}|${PROPOSE_TAKES_PROMPT_VERSION}`]);
|
|
const { engine, captured } = buildMockEngine({ pages, existingProposals: existing });
|
|
let extractorCalled = false;
|
|
const extractor: ProposeTakesExtractor = async () => {
|
|
extractorCalled = true;
|
|
return [];
|
|
};
|
|
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
|
|
expect(extractorCalled).toBe(false);
|
|
const details = result.details as Record<string, unknown>;
|
|
expect(details.cache_hits).toBe(1);
|
|
expect(details.proposals_inserted).toBe(0);
|
|
expect(captured.filter(c => c.sql.includes('INSERT'))).toHaveLength(0);
|
|
});
|
|
|
|
test('passes existing fence rows to extractor as dedup context (F2 fix)', async () => {
|
|
const body = `# Page
|
|
|
|
<!-- gbrain:takes:begin -->
|
|
| # | claim | kind | who | weight | since | source |
|
|
|---|-------|------|-----|--------|-------|--------|
|
|
| 1 | Already captured claim | take | brain | 0.5 | 2026-01 | |
|
|
<!-- gbrain:takes:end -->
|
|
|
|
New prose appended here.`;
|
|
const pages = [buildPage({ slug: 'wiki/existing', body })];
|
|
const { engine } = buildMockEngine({ pages });
|
|
let receivedExistingTakes: unknown;
|
|
const extractor: ProposeTakesExtractor = async ({ existingTakes }) => {
|
|
receivedExistingTakes = existingTakes;
|
|
return [];
|
|
};
|
|
await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
|
|
expect(Array.isArray(receivedExistingTakes)).toBe(true);
|
|
expect((receivedExistingTakes as Array<{ claim: string }>)[0]?.claim).toBe('Already captured claim');
|
|
});
|
|
|
|
test('extractor throw on a single page logs warning + phase continues', async () => {
|
|
const pages = [
|
|
buildPage({ slug: 'wiki/a', body: 'page A prose' }),
|
|
buildPage({ slug: 'wiki/b', body: 'page B prose' }),
|
|
];
|
|
const { engine } = buildMockEngine({ pages });
|
|
let callCount = 0;
|
|
const extractor: ProposeTakesExtractor = async () => {
|
|
callCount++;
|
|
if (callCount === 1) throw new Error('LLM timeout');
|
|
return [{ claim_text: 'second page claim', kind: 'take', holder: 'brain', weight: 0.5 }];
|
|
};
|
|
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
|
|
expect(result.status).toBe('ok');
|
|
const details = result.details as Record<string, unknown>;
|
|
expect(details.pages_scanned).toBe(2);
|
|
expect(details.proposals_inserted).toBe(1);
|
|
expect((details.warnings as string[]).length).toBeGreaterThan(0);
|
|
expect((details.warnings as string[])[0]).toContain('LLM timeout');
|
|
});
|
|
|
|
test('pages with empty compiled_truth are skipped silently (no extractor call)', async () => {
|
|
const pages = [
|
|
buildPage({ slug: 'wiki/empty', body: '' }),
|
|
buildPage({ slug: 'wiki/whitespace', body: ' \n ' }),
|
|
buildPage({ slug: 'wiki/real', body: 'has prose' }),
|
|
];
|
|
const { engine } = buildMockEngine({ pages });
|
|
let extractorCalls = 0;
|
|
const extractor: ProposeTakesExtractor = async () => {
|
|
extractorCalls++;
|
|
return [];
|
|
};
|
|
await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
expect(extractorCalls).toBe(1);
|
|
});
|
|
|
|
test('skipPagesWithFence:true bypasses pages that already have a complete fence', async () => {
|
|
const pages = [
|
|
buildPage({
|
|
slug: 'wiki/fenced',
|
|
body: `<!-- gbrain:takes:begin -->\n| # | claim | kind | who | weight |\n|---|---|---|---|---|\n| 1 | x | take | brain | 0.5 |\n<!-- gbrain:takes:end -->\n\nprose`,
|
|
}),
|
|
buildPage({ slug: 'wiki/unfenced', body: 'plain prose only' }),
|
|
];
|
|
const { engine } = buildMockEngine({ pages });
|
|
let extractorCalls = 0;
|
|
const extractor: ProposeTakesExtractor = async () => {
|
|
extractorCalls++;
|
|
return [];
|
|
};
|
|
await runPhaseProposeTakes(buildCtx(engine), { extractor, skipPagesWithFence: true });
|
|
expect(extractorCalls).toBe(1);
|
|
});
|
|
|
|
test('proposal_run_id is stable across all proposals from one phase invocation', async () => {
|
|
const pages = [
|
|
buildPage({ slug: 'wiki/a', body: 'page a' }),
|
|
buildPage({ slug: 'wiki/b', body: 'page b' }),
|
|
];
|
|
const { engine, captured } = buildMockEngine({ pages });
|
|
const extractor: ProposeTakesExtractor = async () => [
|
|
{ claim_text: 'x', kind: 'take', holder: 'brain', weight: 0.5 },
|
|
];
|
|
await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
|
|
expect(inserts).toHaveLength(2);
|
|
const runIdA = inserts[0]!.params[4];
|
|
const runIdB = inserts[1]!.params[4];
|
|
expect(runIdA).toBe(runIdB);
|
|
expect(typeof runIdA).toBe('string');
|
|
expect((runIdA as string).startsWith('propose-')).toBe(true);
|
|
});
|
|
});
|