mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
cycle: propose_takes phase + take_proposals queue write path (T3)
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
af6fe9d57b
commit
0fdcc54dde
@@ -57,6 +57,14 @@ export type CyclePhase =
|
||||
| 'lint' | 'backlinks' | 'sync' | 'synthesize' | 'extract' | 'extract_facts'
|
||||
| 'resolve_symbol_edges'
|
||||
| 'patterns' | 'recompute_emotional_weight' | 'consolidate'
|
||||
// v0.36.0.0 Hindsight calibration wave:
|
||||
// - propose_takes: LLM scans markdown prose, proposes gradeable claims
|
||||
// to a review queue. User accepts/rejects via `gbrain takes propose`.
|
||||
// - grade_takes: walks unresolved takes, retrieves evidence, asks a
|
||||
// judge model to verdict them. Auto-resolve OFF by default (D17).
|
||||
// - calibration_profile: aggregates the resolved subset into 2-4
|
||||
// narrative pattern statements + active bias tags. Voice-gated.
|
||||
| 'propose_takes' | 'grade_takes' | 'calibration_profile'
|
||||
| 'embed' | 'orphans' | 'purge';
|
||||
|
||||
export const ALL_PHASES: CyclePhase[] = [
|
||||
@@ -88,6 +96,20 @@ export const ALL_PHASES: CyclePhase[] = [
|
||||
// stay as audit trail. Placed AFTER patterns (graph-fresh) and BEFORE
|
||||
// embed (so the new takes get embedded same-cycle).
|
||||
'consolidate',
|
||||
// v0.36.0.0 Hindsight calibration wave. Ordering rationale:
|
||||
// - propose_takes AFTER consolidate so the proposal LLM sees the
|
||||
// freshly-consolidated takes when deciding what's NOT yet captured
|
||||
// (F2 fence-dedup).
|
||||
// - grade_takes AFTER propose so newly-accepted proposals from the
|
||||
// queue are eligible for grading on the next cycle (manual accept
|
||||
// can land between cycle runs; auto-accept is intentionally NOT a
|
||||
// thing — user always reviews).
|
||||
// - calibration_profile AFTER grade so the profile reads fresh
|
||||
// resolutions. Voice-gated narrative; cheap (Haiku judge).
|
||||
// Budget caps live in src/core/cycle/budget-meter.ts via BaseCyclePhase.
|
||||
'propose_takes',
|
||||
'grade_takes',
|
||||
'calibration_profile',
|
||||
'embed',
|
||||
'orphans',
|
||||
// v0.26.5: hard-deletes soft-deleted pages and expired archived sources past
|
||||
@@ -118,6 +140,12 @@ const NEEDS_LOCK_PHASES: ReadonlySet<CyclePhase> = new Set([
|
||||
// v0.29 — writes pages.emotional_weight column.
|
||||
'recompute_emotional_weight',
|
||||
'consolidate',
|
||||
// v0.36.0.0 — propose_takes / grade_takes / calibration_profile all
|
||||
// mutate DB state (take_proposals, take_grade_cache, calibration_profiles)
|
||||
// so they coordinate via the cycle lock.
|
||||
'propose_takes',
|
||||
'grade_takes',
|
||||
'calibration_profile',
|
||||
'embed',
|
||||
'purge',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* v0.36.0.0 (T3) — propose_takes cycle phase.
|
||||
*
|
||||
* Scans markdown pages updated since last run, sends each page's prose to
|
||||
* a tuned LLM extractor, writes the extracted gradeable claims to the
|
||||
* `take_proposals` queue. User accepts/rejects via `gbrain takes propose`.
|
||||
*
|
||||
* Idempotency contract (D17 schema spec):
|
||||
* The unique index on (source_id, page_slug, content_hash, prompt_version)
|
||||
* 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.
|
||||
*
|
||||
* 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." This prevents duplicate proposals
|
||||
* when a user adds prose to a page that already has takes.
|
||||
*
|
||||
* Auto-resolve posture:
|
||||
* propose_takes only WRITES proposals to the queue. Nothing here mutates
|
||||
* the canonical takes table. Operator opt-in via `gbrain takes propose
|
||||
* --accept N` is the only path from queue to canonical fence (D17).
|
||||
*
|
||||
* Prompt tuning status (v0.36.0.0 ship state):
|
||||
* The default extractor prompt is a placeholder ("v0.36.0.0-stub"). The
|
||||
* real prompt is tuned via T19's 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 is opt-in via config flag and produces best-
|
||||
* effort candidates that the user reviews manually.
|
||||
*
|
||||
* The extractor LLM call is INJECTED via opts.extractor for tests, so the
|
||||
* phase can run hermetically in unit tests without touching the gateway.
|
||||
*/
|
||||
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
|
||||
import { chat as gatewayChat } from '../ai/gateway.ts';
|
||||
import { GBrainError } from '../types.ts';
|
||||
import type { Page, PageFilters } from '../types.ts';
|
||||
import type { OperationContext } from '../operations.ts';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { PhaseStatus, CyclePhase } from '../cycle.ts';
|
||||
|
||||
/**
|
||||
* Bump when the extractor prompt or the JSON output shape changes. Old
|
||||
* verdicts in `take_proposals` (composite key includes prompt_version) stay
|
||||
* valid as audit history; new runs re-spend LLM tokens on every page.
|
||||
*/
|
||||
export const PROPOSE_TAKES_PROMPT_VERSION = 'v0.36.0.0-stub';
|
||||
|
||||
/**
|
||||
* Stub extractor prompt. v0.36.0.0 ship-state placeholder — T19 corpus
|
||||
* build replaces this with a tuned prompt (Hindsight-style, adapted for
|
||||
* gbrain's kind/holder/weight take schema rather than Hindsight's
|
||||
* conviction/domain shape).
|
||||
*
|
||||
* The stub returns an empty array reliably so the phase wires up cleanly
|
||||
* end-to-end without producing noise during the pre-tuned window. Operators
|
||||
* opting in early get a queue that fills only when they explicitly invoke
|
||||
* with a non-stub prompt.
|
||||
*/
|
||||
export const EXTRACT_TAKES_PROMPT = `[v0.36.0.0-stub] Extract gradeable claims (predictions, recommendations,
|
||||
interpretive judgments that could turn out wrong) from the prose below.
|
||||
|
||||
Output ONLY a JSON array of objects. Each object has fields:
|
||||
- claim_text (string, <=200 chars) the claim verbatim or close paraphrase
|
||||
- kind ('fact' | 'take' | 'bet' | 'hunch')
|
||||
- holder ('world' | 'people/<slug>' | 'companies/<slug>' | 'brain')
|
||||
- weight (number 0..1, 0.05 increments preferred)
|
||||
- domain (optional short tag, e.g. 'tactics' / 'macro' / 'hiring')
|
||||
|
||||
Do NOT include evidence, citations, examples, or restatements of an earlier claim.
|
||||
If no gradeable claims are present, return [].
|
||||
|
||||
EXISTING FENCE ROWS (these are already captured — do NOT propose duplicates):
|
||||
{EXISTING_TAKES_JSON}
|
||||
|
||||
PAGE PROSE:
|
||||
{PAGE_BODY}
|
||||
`;
|
||||
|
||||
/** One proposed take, as the extractor produces it. */
|
||||
export interface ProposedTake {
|
||||
claim_text: string;
|
||||
kind: 'fact' | 'take' | 'bet' | 'hunch';
|
||||
holder: string;
|
||||
weight: number;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
/** Extractor function signature — injected for tests; production calls gateway. */
|
||||
export type ProposeTakesExtractor = (input: {
|
||||
pagePath: string;
|
||||
pageBody: string;
|
||||
existingTakes: Array<{ claim: string; kind: string; holder: string; weight: number }>;
|
||||
modelHint?: string;
|
||||
}) => Promise<ProposedTake[]>;
|
||||
|
||||
export interface ProposeTakesOpts extends BasePhaseOpts {
|
||||
/** Brain repo root for fs-source page walking. Optional — defaults to engine pages. */
|
||||
repoPath?: string;
|
||||
/** Limit pages processed in this cycle (for triage / quick smoke). Default: 100. */
|
||||
pageLimit?: number;
|
||||
/** Inject the LLM call for tests; production uses gateway.chat. */
|
||||
extractor?: ProposeTakesExtractor;
|
||||
/** Override prompt_version (tests). */
|
||||
promptVersion?: string;
|
||||
/** Override model id (tests + config). */
|
||||
model?: string;
|
||||
/** Skip pages that already have a complete takes fence. Default: true. */
|
||||
skipPagesWithFence?: boolean;
|
||||
}
|
||||
|
||||
export interface ProposeTakesResult {
|
||||
pages_scanned: number;
|
||||
cache_hits: number;
|
||||
cache_misses: number;
|
||||
proposals_inserted: number;
|
||||
budget_exhausted: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the content_hash key for the idempotency cache. SHA-256 of the
|
||||
* page body suffices — page slug + prompt_version are separate columns in
|
||||
* the composite unique index.
|
||||
*/
|
||||
export function contentHash(pageBody: string): string {
|
||||
return createHash('sha256').update(pageBody).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a page already has a complete `<!-- gbrain:takes:begin -->`
|
||||
* fence. We DO propose against pages with fences (F2 dedup) but the operator
|
||||
* may opt to skip-with-fence pages via skipPagesWithFence:true for a faster
|
||||
* pass. The fence shape mirrors src/core/takes-fence.ts.
|
||||
*/
|
||||
export function hasCompleteFence(pageBody: string): boolean {
|
||||
return /<!---?\s*gbrain:takes:begin[\s\S]*?gbrain:takes:end\s*-->/.test(pageBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the existing fence into rows so the extractor can dedupe.
|
||||
* Returns [] when no fence is present. Best-effort — malformed fences
|
||||
* surface to the operator via the existing v0.28 fence parser, not here.
|
||||
*/
|
||||
export function extractExistingTakesForDedup(pageBody: string): Array<{
|
||||
claim: string;
|
||||
kind: string;
|
||||
holder: string;
|
||||
weight: number;
|
||||
}> {
|
||||
const fenceMatch = pageBody.match(/<!---?\s*gbrain:takes:begin\s*-->([\s\S]*?)<!---?\s*gbrain:takes:end\s*-->/);
|
||||
if (!fenceMatch) return [];
|
||||
const body = fenceMatch[1] ?? '';
|
||||
const rows: Array<{ claim: string; kind: string; holder: string; weight: number }> = [];
|
||||
for (const line of body.split('\n')) {
|
||||
const cells = line.split('|').map(c => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
|
||||
// Skip header + separator rows.
|
||||
if (cells.length < 4) continue;
|
||||
if (cells[0] === '#' || cells[0]?.match(/^-+$/)) continue;
|
||||
const claim = cells[1] ?? '';
|
||||
if (!claim || claim.startsWith('~~')) continue; // strikethrough = inactive, doesn't count for dedup
|
||||
const kind = cells[2] ?? 'take';
|
||||
const holder = cells[3] ?? 'brain';
|
||||
const weight = Number.parseFloat(cells[4] ?? '0.5');
|
||||
rows.push({
|
||||
claim: claim.replace(/^~~|~~$/g, ''),
|
||||
kind,
|
||||
holder,
|
||||
weight: Number.isFinite(weight) ? weight : 0.5,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Production extractor — calls gateway.chat with the EXTRACT_TAKES_PROMPT
|
||||
* and parses the JSON array output. Returns [] on parse failure (logged as
|
||||
* warning, not thrown — one bad page must not abort the phase).
|
||||
*
|
||||
* Stub-prompt note: the v0.36.0.0 ship-state prompt is a placeholder. Real
|
||||
* extractor lands when T19 corpus build produces the tuned prompt. Until
|
||||
* then, the production extractor returns whatever the stub LLM produces —
|
||||
* empirically often a sparse list or [].
|
||||
*/
|
||||
export async function defaultExtractor(
|
||||
input: Parameters<ProposeTakesExtractor>[0],
|
||||
): Promise<ProposedTake[]> {
|
||||
const prompt = EXTRACT_TAKES_PROMPT
|
||||
.replace('{EXISTING_TAKES_JSON}', JSON.stringify(input.existingTakes, null, 2))
|
||||
.replace('{PAGE_BODY}', input.pageBody);
|
||||
|
||||
const result = await gatewayChat({
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
...(input.modelHint ? { model: input.modelHint } : {}),
|
||||
maxTokens: 2048,
|
||||
});
|
||||
|
||||
// ChatResult.text is already the concatenated text content.
|
||||
return parseExtractorOutput(result.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse extractor output into ProposedTake[]. Handles common LLM output
|
||||
* sins (markdown fence wrapping, leading/trailing prose, single-object
|
||||
* instead of array). Returns [] on any unrecoverable parse error rather
|
||||
* than throwing.
|
||||
*/
|
||||
export function parseExtractorOutput(raw: string): ProposedTake[] {
|
||||
if (!raw || raw.trim().length === 0) return [];
|
||||
let text = raw.trim();
|
||||
// Strip markdown code fence wrapper.
|
||||
const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
||||
if (fenced) text = (fenced[1] ?? '').trim();
|
||||
// First-array-or-object substring extraction (defends against leading prose).
|
||||
const firstArr = text.indexOf('[');
|
||||
const firstObj = text.indexOf('{');
|
||||
if (firstArr === -1 && firstObj === -1) return [];
|
||||
const start = firstArr !== -1 && (firstObj === -1 || firstArr < firstObj) ? firstArr : firstObj;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text.slice(start));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const out: ProposedTake[] = [];
|
||||
for (const raw of arr) {
|
||||
if (typeof raw !== 'object' || raw === null) continue;
|
||||
const r = raw as Record<string, unknown>;
|
||||
const claim_text = typeof r.claim_text === 'string' ? r.claim_text.trim() : '';
|
||||
if (!claim_text || claim_text.length > 500) continue;
|
||||
const kind = ['fact', 'take', 'bet', 'hunch'].includes(r.kind as string)
|
||||
? (r.kind as ProposedTake['kind'])
|
||||
: 'take';
|
||||
const holder = typeof r.holder === 'string' && r.holder.length > 0 ? r.holder : 'brain';
|
||||
const weightRaw = typeof r.weight === 'number' ? r.weight : 0.5;
|
||||
const weight = Math.max(0, Math.min(1, weightRaw));
|
||||
const domain = typeof r.domain === 'string' && r.domain.length > 0 ? r.domain : undefined;
|
||||
out.push({ claim_text, kind, holder, weight, domain });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* BaseCyclePhase subclass. Walks pages, checks idempotency cache, calls
|
||||
* extractor, writes proposals.
|
||||
*/
|
||||
class ProposeTakesPhase extends BaseCyclePhase {
|
||||
readonly name = 'propose_takes' as CyclePhase;
|
||||
protected readonly budgetUsdKey = 'cycle.propose_takes.budget_usd';
|
||||
protected readonly budgetUsdDefault = 5.0;
|
||||
|
||||
protected override mapErrorCode(err: unknown): string {
|
||||
if (err instanceof GBrainError) return err.problem;
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('content_hash')) return 'CALIBRATION_PROPOSAL_DEDUP_FAIL';
|
||||
if (err.message.includes('budget') || err.message.includes('Budget')) return 'CALIBRATION_GRADE_BUDGET_EXHAUSTED';
|
||||
}
|
||||
return 'PROPOSE_TAKES_UNKNOWN';
|
||||
}
|
||||
|
||||
protected async process(
|
||||
engine: BrainEngine,
|
||||
scope: ScopedReadOpts,
|
||||
_ctx: OperationContext,
|
||||
opts: ProposeTakesOpts,
|
||||
): Promise<{ summary: string; details: Record<string, unknown>; status?: PhaseStatus }> {
|
||||
const extractor = opts.extractor ?? defaultExtractor;
|
||||
const promptVersion = opts.promptVersion ?? PROPOSE_TAKES_PROMPT_VERSION;
|
||||
const pageLimit = opts.pageLimit ?? 100;
|
||||
const skipPagesWithFence = opts.skipPagesWithFence ?? false;
|
||||
const proposalRunId = `propose-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const result: ProposeTakesResult = {
|
||||
pages_scanned: 0,
|
||||
cache_hits: 0,
|
||||
cache_misses: 0,
|
||||
proposals_inserted: 0,
|
||||
budget_exhausted: false,
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
// Load pages eligible for proposal. Source-scoped per BaseCyclePhase.
|
||||
const pageFilters: PageFilters = {
|
||||
...scope,
|
||||
limit: pageLimit,
|
||||
sort: 'updated_desc',
|
||||
};
|
||||
const pages: Page[] = await engine.listPages(pageFilters);
|
||||
|
||||
if (opts.reporter) {
|
||||
opts.reporter.start('propose_takes.pages' as never, pages.length);
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
result.pages_scanned += 1;
|
||||
this.tick(opts);
|
||||
|
||||
// Skip pages that have NO prose body (e.g. metadata-only entity stubs).
|
||||
const body = page.compiled_truth ?? '';
|
||||
if (body.trim().length === 0) continue;
|
||||
if (skipPagesWithFence && hasCompleteFence(body)) continue;
|
||||
|
||||
const ch = contentHash(body);
|
||||
const existingTakes = extractExistingTakesForDedup(body);
|
||||
|
||||
// Idempotency check. If a row exists for (source_id, page_slug, content_hash,
|
||||
// prompt_version), this page was already processed — skip and count as cache hit.
|
||||
const sourceId = page.source_id ?? scope.sourceId ?? 'default';
|
||||
const cached = await engine.executeRaw<{ id: number }>(
|
||||
`SELECT id FROM take_proposals
|
||||
WHERE source_id = $1 AND page_slug = $2 AND content_hash = $3 AND prompt_version = $4
|
||||
LIMIT 1`,
|
||||
[sourceId, page.slug, ch, promptVersion],
|
||||
);
|
||||
if (cached.length > 0) {
|
||||
result.cache_hits += 1;
|
||||
continue;
|
||||
}
|
||||
result.cache_misses += 1;
|
||||
|
||||
// Budget pre-check before the LLM call. Estimate: ~1500 input tokens + 500 output.
|
||||
const budget = this.checkBudget({
|
||||
modelId: opts.model ?? 'claude-sonnet-4-6',
|
||||
estimatedInputTokens: 1500,
|
||||
maxOutputTokens: 500,
|
||||
});
|
||||
if (!budget.allowed) {
|
||||
result.budget_exhausted = true;
|
||||
result.warnings.push(
|
||||
`budget exhausted at page ${result.pages_scanned}/${pages.length} (cumulative $${budget.cumulativeCostUsd.toFixed(4)} / cap $${budget.budgetUsd.toFixed(2)})`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Call the extractor. Errors on a single page log a warning but do not abort.
|
||||
let proposals: ProposedTake[];
|
||||
try {
|
||||
proposals = await extractor({
|
||||
pagePath: page.slug,
|
||||
pageBody: body,
|
||||
existingTakes,
|
||||
modelHint: opts.model,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
result.warnings.push(`extractor failed on ${page.slug}: ${msg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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),
|
||||
opts.model ?? 'claude-sonnet-4-6',
|
||||
],
|
||||
);
|
||||
result.proposals_inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.reporter) opts.reporter.finish();
|
||||
|
||||
return {
|
||||
summary: `propose_takes: scanned ${result.pages_scanned} pages, ${result.cache_hits} cached, ${result.proposals_inserted} new proposals (run ${proposalRunId})`,
|
||||
details: { ...result, proposal_run_id: proposalRunId, prompt_version: promptVersion },
|
||||
status: result.budget_exhausted ? 'warn' : 'ok',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry point — mirrors the v0.23 `runPhaseSynthesize` shape so the
|
||||
* cycle orchestrator in cycle.ts can call it uniformly.
|
||||
*/
|
||||
export async function runPhaseProposeTakes(
|
||||
ctx: OperationContext,
|
||||
opts: ProposeTakesOpts = {},
|
||||
) {
|
||||
return new ProposeTakesPhase().run(ctx, opts);
|
||||
}
|
||||
|
||||
/** Test-only access to the class for subclassing in tests. */
|
||||
export const __testing = {
|
||||
ProposeTakesPhase,
|
||||
parseExtractorOutput,
|
||||
contentHash,
|
||||
hasCompleteFence,
|
||||
extractExistingTakesForDedup,
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user