Compare commits

...
Author SHA1 Message Date
4a23e8319e fix(propose-takes): negative-cache zero-proposal pages with a sentinel row (#2106)
propose_takes claims idempotency (an unchanged page never re-spends LLM
tokens) but only stored the key for pages that produced >=1 take. A page
yielding zero proposals never got a take_proposals row, so it was re-sent
to the LLM every cycle — the common case in a steady brain.

Insert a '__no_proposals__' sentinel row when the extractor returns [].
The existing composite unique index (source_id, page_slug, content_hash,
prompt_version) makes the next cycle's SELECT hit and skip the LLM call.
Edited pages (new content_hash) and prompt bumps still re-extract, and
extractor errors deliberately do NOT write a sentinel.

Takeover of #2261: preserves modelId routing for the model_id column
(the original branch hardcoded a fallback, breaking gateway model-routing
tests) and drops an unrelated .gitignore hunk.

Co-authored-by: Quoc Vu <quocvu2640@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:53:56 -07:00
2 changed files with 48 additions and 8 deletions
+32 -8
View File
@@ -391,12 +391,17 @@ class ProposeTakesPhase extends BaseCyclePhase {
// Write proposals to take_proposals. Each row is a separate INSERT
// because the composite idempotency key is on the per-page tuple — a
// bulk UPSERT would collapse a same-page-multi-claim run into one row.
for (const p of proposals) {
if (proposals.length === 0) {
// #2106 negative cache: insert a sentinel row when the extractor
// found nothing, so the idempotency check skips this page on future
// cycles. Without it, pages with no gradeable claims (the common
// case) re-spend LLM tokens every cycle. Same composite key rules:
// an edited page (new content_hash) or a prompt bump re-extracts.
await engine.executeRaw(
`INSERT INTO take_proposals
(source_id, page_slug, content_hash, prompt_version, proposal_run_id,
claim_text, kind, holder, weight, domain, dedup_against_fence_rows, model_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
VALUES ($1, $2, $3, $4, $5, '__no_proposals__', 'take', 'brain', 0, NULL, $6, $7)
ON CONFLICT (source_id, page_slug, content_hash, prompt_version) DO NOTHING`,
[
sourceId,
@@ -404,16 +409,35 @@ class ProposeTakesPhase extends BaseCyclePhase {
ch,
promptVersion,
proposalRunId,
p.claim_text,
p.kind,
p.holder,
p.weight,
p.domain ?? null,
JSON.stringify(existingTakes),
modelId,
],
);
result.proposals_inserted += 1;
} else {
for (const p of proposals) {
await engine.executeRaw(
`INSERT INTO take_proposals
(source_id, page_slug, content_hash, prompt_version, proposal_run_id,
claim_text, kind, holder, weight, domain, dedup_against_fence_rows, model_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (source_id, page_slug, content_hash, prompt_version) DO NOTHING`,
[
sourceId,
page.slug,
ch,
promptVersion,
proposalRunId,
p.claim_text,
p.kind,
p.holder,
p.weight,
p.domain ?? null,
JSON.stringify(existingTakes),
modelId,
],
);
result.proposals_inserted += 1;
}
}
}
+16
View File
@@ -334,6 +334,22 @@ New prose appended here.`;
expect((details.warnings as string[])[0]).toContain('LLM timeout');
});
test('zero-proposal page writes a __no_proposals__ sentinel so the next cycle cache-hits (#2106)', async () => {
const pages = [buildPage({ slug: 'wiki/no-claims', body: 'reference prose with nothing takeable' })];
const { engine, captured } = buildMockEngine({ pages });
const extractor: ProposeTakesExtractor = async () => [];
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
expect(result.status).toBe('ok');
const details = result.details as Record<string, unknown>;
expect(details.proposals_inserted).toBe(0); // sentinel is not a proposal
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
expect(inserts).toHaveLength(1);
expect(inserts[0]!.sql).toContain("'__no_proposals__'");
expect(inserts[0]!.sql).toContain('ON CONFLICT (source_id, page_slug, content_hash, prompt_version)');
expect(inserts[0]!.params).toHaveLength(7); // model_id rides as the last param
});
test('pages with empty compiled_truth are skipped silently (no extractor call)', async () => {
const pages = [
buildPage({ slug: 'wiki/empty', body: '' }),