mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
Reasoning models (MiniMax-M3, DeepSeek-R1, etc.) return <think>...</think>
tags in the content field before the actual JSON output. This caused
parseExtractorOutput to fail in two ways:
1. The fence regex /^\`\`\`(json)?...$/ requires the fence at text start;
<think> preceding it prevents matching, so the raw text (with trailing
fences) hits JSON.parse and throws.
2. When think tags contain [ or { characters, indexOf finds them inside
the reasoning block instead of the actual JSON array.
Changes:
- Strip <think>...</think> tags before any parsing (covers all reasoning models)
- Add JSON.parse fallback: truncate at last ] or } to handle trailing
noise (leftover markdown fences after stripping)
Tests: 28/28 pass (3 new cases for think tags + trailing noise).
Co-authored-by: qaz8545355 <junjun@openclaw.local>
574 lines
24 KiB
TypeScript
574 lines
24 KiB
TypeScript
/**
|
|
* v0.36.1.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,
|
|
isWellFormedEmptyExtraction,
|
|
PROPOSE_TAKES_PROMPT_VERSION,
|
|
EMPTY_EXTRACTION_TOMBSTONE_TEXT,
|
|
type ProposeTakesExtractor,
|
|
type ProposedTake,
|
|
} from '../src/core/cycle/propose-takes.ts';
|
|
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
|
import { BudgetMeter } from '../src/core/cycle/budget-meter.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 into take_proposals — persist the idempotency key so a
|
|
// subsequent cycle observes a cache hit, mirroring the real unique
|
|
// index on (source_id, page_slug, content_hash, prompt_version).
|
|
if (sql.includes('INSERT INTO take_proposals')) {
|
|
const [sourceId, slug, ch, pv] = params ?? [];
|
|
existing.add(`${sourceId}|${slug}|${ch}|${pv}`);
|
|
return [];
|
|
}
|
|
// Other writes — 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');
|
|
});
|
|
|
|
test('strips <think> reasoning tags before parsing (MiniMax-M3, DeepSeek-R1)', () => {
|
|
const raw = '<think>Analyzing the prose... I see several claims.</think>\n\n```json\n[{"claim_text":"X","kind":"take","holder":"brain","weight":0.5}]\n```';
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.claim_text).toBe('X');
|
|
});
|
|
|
|
test('strips multiple <think> blocks', () => {
|
|
const raw = '<think>First thought.</think>\n<tool_call>...</tool_call>\n<think>Second thought.</think>\n\n[{"claim_text":"Y","kind":"bet","holder":"brain","weight":0.7}]';
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out).toHaveLength(1);
|
|
});
|
|
|
|
test('handles trailing noise after JSON (leftover fences)', () => {
|
|
const raw = '<think>done</think>\n```json\n[{"claim_text":"Z","kind":"take","holder":"brain","weight":0.6}]\n```\n';
|
|
const out = parseExtractorOutput(raw);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]!.claim_text).toBe('Z');
|
|
});
|
|
});
|
|
|
|
// ─── isWellFormedEmptyExtraction ────────────────────────────────────
|
|
// Guards the tombstone against permanently memoizing a transient parse
|
|
// failure as "no claims". Only a cleanly-parsed empty array counts as a
|
|
// genuine empty extraction; malformed/prose/truncated output must not.
|
|
|
|
describe('isWellFormedEmptyExtraction', () => {
|
|
test('true for a clean empty array (the well-behaved "no claims" response)', () => {
|
|
expect(isWellFormedEmptyExtraction('[]')).toBe(true);
|
|
expect(isWellFormedEmptyExtraction(' [] ')).toBe(true);
|
|
expect(isWellFormedEmptyExtraction('[ ]')).toBe(true);
|
|
});
|
|
|
|
test('true for a fenced empty array', () => {
|
|
expect(isWellFormedEmptyExtraction('```json\n[]\n```')).toBe(true);
|
|
});
|
|
|
|
test('true for leading prose then an empty array', () => {
|
|
expect(isWellFormedEmptyExtraction('No gradeable claims.\n\n[]')).toBe(true);
|
|
});
|
|
|
|
test('false for empty / whitespace output (transient, must retry)', () => {
|
|
expect(isWellFormedEmptyExtraction('')).toBe(false);
|
|
expect(isWellFormedEmptyExtraction(' \n ')).toBe(false);
|
|
});
|
|
|
|
test('false for prose-only / non-JSON output (transient, must retry)', () => {
|
|
expect(isWellFormedEmptyExtraction('There are no gradeable claims here.')).toBe(false);
|
|
expect(isWellFormedEmptyExtraction('null')).toBe(false);
|
|
});
|
|
|
|
test('false for malformed / truncated JSON (transient, must retry)', () => {
|
|
expect(isWellFormedEmptyExtraction('[')).toBe(false);
|
|
expect(isWellFormedEmptyExtraction('[{"claim_text":"x"')).toBe(false);
|
|
});
|
|
|
|
test('false for a NON-empty array (has content — not an empty extraction)', () => {
|
|
expect(isWellFormedEmptyExtraction('[{"claim_text":"x","kind":"take","holder":"brain","weight":0.5}]')).toBe(false);
|
|
// Parseable but claim-less array is ambiguous garbage → not a genuine empty.
|
|
expect(isWellFormedEmptyExtraction('[{"foo":"bar"}]')).toBe(false);
|
|
});
|
|
|
|
test('false for an empty object (model ignored the array-format instruction)', () => {
|
|
expect(isWellFormedEmptyExtraction('{}')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── 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);
|
|
// v0.42: extract rollup row UPSERTs on every phase invocation (best-
|
|
// effort cache). Filter the assertion to take_proposals INSERTs only.
|
|
expect(captured.filter(c => c.sql.includes('INSERT INTO take_proposals'))).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);
|
|
});
|
|
|
|
test('records the configured gateway chat model when no phase model override is passed', async () => {
|
|
configureGateway({
|
|
chat_model: 'openai:gpt-5',
|
|
env: { OPENAI_API_KEY: 'test-key' },
|
|
});
|
|
try {
|
|
const pages = [buildPage({ slug: 'wiki/model-default', body: 'configured model should be recorded' })];
|
|
const { engine, captured } = buildMockEngine({ pages });
|
|
const extractor: ProposeTakesExtractor = async () => [
|
|
{ claim_text: 'configured model should be recorded', kind: 'take', holder: 'brain', weight: 0.5 },
|
|
];
|
|
|
|
await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
|
|
const insert = captured.find(c => c.sql.includes('INSERT INTO take_proposals'));
|
|
expect(insert).toBeDefined();
|
|
expect(insert!.params[11]).toBe('openai:gpt-5');
|
|
} finally {
|
|
resetGateway();
|
|
}
|
|
});
|
|
|
|
test('keeps nested provider model ids intact for budget checks and proposal records', async () => {
|
|
configureGateway({
|
|
chat_model: 'openrouter:anthropic/claude-sonnet-4-6',
|
|
env: { OPENROUTER_API_KEY: 'test-key' },
|
|
});
|
|
try {
|
|
const pages = [buildPage({ slug: 'wiki/openrouter-model', body: 'nested provider model should stay intact' })];
|
|
const { engine, captured } = buildMockEngine({ pages });
|
|
const extractor: ProposeTakesExtractor = async () => [
|
|
{ claim_text: 'nested provider model should stay intact', kind: 'take', holder: 'brain', weight: 0.5 },
|
|
];
|
|
|
|
const result = await runPhaseProposeTakes(buildCtx(engine), {
|
|
extractor,
|
|
meter: new BudgetMeter({ budgetUsd: 0.000001, phase: 'propose_takes' }),
|
|
});
|
|
|
|
expect(result.status).toBe('ok');
|
|
expect(result.details.budget_exhausted).toBe(false);
|
|
const insert = captured.find(c => c.sql.includes('INSERT INTO take_proposals'));
|
|
expect(insert).toBeDefined();
|
|
expect(insert!.params[11]).toBe('openrouter:anthropic/claude-sonnet-4-6');
|
|
} finally {
|
|
resetGateway();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Empty-extraction memoization (idle-cost fix) ───────────────────
|
|
// A page that yields zero gradeable claims must still record an
|
|
// idempotency row, or every cycle re-spends an LLM call on unchanged
|
|
// prose. Regression guard for the "empty result never memoized" bug.
|
|
|
|
describe('runPhaseProposeTakes — empty extraction memoization', () => {
|
|
test('zero-claim page writes a tombstone row (proposals_inserted stays 0)', async () => {
|
|
const pages = [buildPage({ slug: 'test/embed-probe', body: '# probe\njust a test, nothing to grade.' })];
|
|
const { engine, captured } = buildMockEngine({ pages });
|
|
const extractor: ProposeTakesExtractor = async () => [];
|
|
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
|
|
const details = result.details as Record<string, unknown>;
|
|
expect(details.cache_misses).toBe(1);
|
|
expect(details.proposals_inserted).toBe(0);
|
|
expect(details.tombstones_written).toBe(1);
|
|
|
|
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
|
|
expect(inserts).toHaveLength(1);
|
|
// Tombstone carries the sentinel claim_text and an out-of-queue status.
|
|
expect(inserts[0]!.params[5]).toBe(EMPTY_EXTRACTION_TOMBSTONE_TEXT); // claim_text
|
|
expect(inserts[0]!.sql).toContain("'rejected'");
|
|
});
|
|
|
|
test('unchanged zero-claim page is a cache hit next cycle (no repeat LLM call)', async () => {
|
|
const pages = [buildPage({ slug: 'test/embed-probe', body: '# probe\njust a test, nothing to grade.' })];
|
|
const { engine } = buildMockEngine({ pages });
|
|
let extractorCalls = 0;
|
|
const extractor: ProposeTakesExtractor = async () => {
|
|
extractorCalls++;
|
|
return [];
|
|
};
|
|
|
|
// Cycle 1: cache miss → LLM call → tombstone written.
|
|
const r1 = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
expect(extractorCalls).toBe(1);
|
|
expect((r1.details as Record<string, unknown>).cache_misses).toBe(1);
|
|
expect((r1.details as Record<string, unknown>).tombstones_written).toBe(1);
|
|
|
|
// Cycle 2: same unchanged page → cache hit → extractor NOT called again.
|
|
const r2 = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
expect(extractorCalls).toBe(1); // the whole point: no re-spend
|
|
expect((r2.details as Record<string, unknown>).cache_hits).toBe(1);
|
|
expect((r2.details as Record<string, unknown>).cache_misses).toBe(0);
|
|
});
|
|
|
|
test('extractor error does NOT write a tombstone (page retried next cycle)', async () => {
|
|
const pages = [buildPage({ slug: 'wiki/x', body: 'some prose' })];
|
|
const { engine, captured } = buildMockEngine({ pages });
|
|
const extractor: ProposeTakesExtractor = async () => {
|
|
throw new Error('LLM timeout');
|
|
};
|
|
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
|
|
|
expect((result.details as Record<string, unknown>).tombstones_written).toBe(0);
|
|
expect(captured.filter(c => c.sql.includes('INSERT INTO take_proposals'))).toHaveLength(0);
|
|
});
|
|
});
|