mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* v0.41.11.0 feat: conversation retrieval upgrade — production-bar replacement for PR #1406 Long chat threads stop swallowing your search results. The recall miss class on long iMessage/Slack imports (60K+ msg history; a chunk that reads only "Locker 93 code 9494" has no topical anchor because "cabin" was established 50K messages earlier) gets fixed by walking conversation/meeting/slack/email pages, splitting into time-windowed segments (30-min gap or 30-msg cap), prepending a topical/temporal header, and running through the existing extractFactsFromTurn() so the resulting anchor-rich facts surface in gbrain search. This is the production-bar replacement for PR #1406 (which closes LAST per Codex T6d, AFTER this PR is green). The bug fix survives 1:1; the wrapping closes 14 load-bearing issues the original PR deferred or shipped silent bugs around. The wave went through CEO scope review, 3 rounds of spec review, 2 rounds of Codex outside voice grounding the plan against actual code, and 2 passes of eng review. Version-slot note: originally planned as v0.41.2.0; master shipped its own v0.41.2.0 (lens packs) plus v0.41.3-6.0 between plan-time and ship-time. Re-bumped to v0.41.11.0 (next free slot; v0.41.7-10 claimed by other open PRs). Key files (new): - src/commands/extract-conversation-facts.ts — CLI command with --types, --max-cost-usd, --background, --override-disabled, --slug, --dry-run, --limit, --since, --force, --sleep, --segment-limit, --source-id. Strict per-source core; two-phase page enumeration (paginated listPages with 10×25MB cap = 250MB worst case); 25MB body cap; page-global row_num accumulator (Codex C1 unique-index collision fix); page-level TERMINAL audit row after all segments commit (Codex C7 durable extraction marker); optional opts.budgetTracker (Codex C5 — nested withBudgetTracker REPLACES, so caller-managed scope passes tracker through); reads compiled_truth + timeline (F1 — PR silently dropped timeline half); honors facts.extraction_enabled kill-switch with --override-disabled escape (F2); --types reads cycle config as single source of truth (Eng-v2 A2); fingerprint on sourceId only (Eng-v2 A3 — widening types doesn't invalidate completion); string-encoded op-checkpoint entries "sourceId|slug|endIso" for resume; segment caps tuned 6500/30 (Eng-v2 T5) to stay under extract.ts MAX_TURN_TEXT_CHARS=8000. - src/core/cycle/conversation-facts-backfill.ts — cycle phase wrapper (default OFF). Iterates listSources() directly; creates ONE brain-wide BudgetTracker per tick + wraps the loop in withBudgetTracker + passes tracker through opts.budgetTracker so core doesn't nest-replace. Two-layer cost AND walltime protection: per-source caps ($1, 20min) AND brain-wide caps ($5, 30min). - test/extract-conversation-facts.test.ts — 27 unit cases (parse, segment, render, checkpoint encoding, fingerprint, terminal audit row, row_num accumulator, F2 kill-switch, --override-disabled). - skills/migrations/v0.41.11.0.md — agent-facing migration guide. Key files (modified): - src/commands/jobs.ts — register extract-conversation-facts Minion handler. NOT in PROTECTED_JOB_NAMES; BudgetExhausted catch + persist + mark completed with result.budget_exhausted (NOT a failure). - src/commands/doctor.ts — computeConversationFactsBacklogCheck (3-state: SKIPPED when feature disabled per Eng-v2 C9, OK at backlog=0, WARN at >10 with paste-ready remediation step via makeRemediationStep). Doctor query is source-scoped (Codex C2 cross-source safety) and matches the TERMINAL audit row (Codex C7), not any-fact-for-slug. - src/commands/sources.ts — runAudit extended with facts_backfill_estimate field for cost preview. - src/cli.ts — CLI_ONLY + CLI_ONLY_SELF_HELP + THIN_CLIENT_REFUSED_COMMANDS + dispatch case for extract-conversation-facts. - src/core/cycle.ts — new CyclePhase 'conversation_facts_backfill'; PHASE_SCOPE='source' (taxonomy only per cycle.ts:131 — wrapper does own multi-source iteration); wired into ALL_PHASES + NEEDS_LOCK_PHASES; dispatch block runs between consolidate and embed. - src/core/migrate.ts — migration v94 adds partial index idx_facts_extract_conversation_session ON facts(source_id, source_session) WHERE source LIKE 'cli:extract-conversation-facts%' so doctor query stays fast on million-fact brains. v14 precedent: transaction:false + invalid-index pre-drop on Postgres, plain CREATE INDEX on PGLite. - src/core/schema-pack/base/gbrain-base.yaml — promote conversation (temporal, extractable) and atom (annotation, NOT extractable — atoms ARE the extracted form) into base. Flip concept.extractable: true semantically (cosmetic on backstop path per Codex T3; the original grandfather migration was solving a phantom, dropped). Filing rules added for both new types. - src/core/schema-pack/base/gbrain-recommended.yaml — remove duplicate conversation (now inherits via extends: gbrain-base). - src/core/types.ts — ALL_PAGE_TYPES extended with conversation, atom. - test/extractable-pack.test.ts — updated parity gate (24 page types vs PR's 22; concept + conversation now extractable, atom not). - test/schema-cli.test.ts — page-count expectation 22→24. - VERSION + package.json bumped to 0.41.11.0. - CHANGELOG.md release-summary in the required ELI10-first voice + itemized changes section. - CLAUDE.md Key Files entry for the new modules + architecture notes. - llms.txt + llms-full.txt regenerated. Plan + decisions persisted at: ~/.claude/plans/system-instruction-you-are-working-linear-unicorn.md CEO plan at: ~/.gstack/projects/garrytan-gbrain/ceo-plans/2026-05-25-conversation-retrieval-upgrade.md Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: align v0.41.11.0 phase ordering + bump hardcoded counts after master merge Three CI failures from the master merge in this branch: 1. test/phase-scope-coverage.test.ts pinned `ALL_PHASES.length === 19` and `Object.keys(PHASE_SCOPE).length === 19`. After merging master's v0.41 lens-packs (extract_atoms + synthesize_concepts) + my new conversation_facts_backfill phase, the total is 20. 2. test/core/cycle.serial.test.ts had two hardcoded `19` assertions (`hookCalls` and `report.phases.length`) tracking the same count. Both bumped to 20. 3. cycle.serial's `'default: all 6 phases run in order'` test asserts `report.phases.map(p => p.phase) === ALL_PHASES`. My initial commit put `conversation_facts_backfill` in ALL_PHASES between consolidate and propose_takes, but the runCycle dispatch block runs it AFTER the calibration trio (propose_takes / grade_takes / calibration_profile) and BEFORE embed. List and dispatch order didn't match, so the equality assertion failed. Resolution: moved 'conversation_facts_backfill' in ALL_PHASES to AFTER 'calibration_profile' so list-order matches dispatch-order. The dispatch block placement was correct (and remains correct); the list-position comment originally said "AFTER consolidate" but the dispatch runs it after the WHOLE consolidate→calibration_profile block, not just after consolidate. Comment now reflects reality. Verified: 61/61 pass across the 3 affected test files (2.9s wallclock). The CI logs also showed a "(unnamed) [3058.07ms]" failure in shard 1; unable to reproduce locally (test/scripts/run-unit-parallel.test.ts passes 6/6 in 1s). Suspected CI-load flakiness under bun's parallel scheduler. If it persists on the next CI run, will dig in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): orphan sleep cleanup in run-unit-parallel.sh heartbeat Two CI runs in a row reported `(fail) (unnamed) [~2400ms]` in shard 1 on this PR. Investigation: - CI's end-of-job cleanup logged: "Terminate orphan process: pid (3344) (sleep)" × 6 sleep processes. - The 6 matches exactly the 6 `runWrapper()` calls in test/scripts/run-unit-parallel.test.ts (1 orphan sleep per invocation). - Each `runWrapper()` spawns scripts/run-unit-parallel.sh, which spawns a heartbeat function that runs `while true; do sleep 10; ...; done` in the background. - The wrapper's EXIT trap was `kill "$HB_PID" 2>/dev/null` — kills the heartbeat shell, but its currently-running `sleep 10` child gets reparented to init/launchd because SIGTERM to a bash shell sleeping inside `sleep` doesn't propagate to the sleep child before wait returns. Known bash quirk on Linux. - bun's test runner treats the orphan sleeps as a `(unnamed)` failure attributed to the test file that spawned the wrapper. Fix: pkill children FIRST, then kill heartbeat. If we kill heartbeat first, its child sleep orphans and pkill -P can no longer find it (ppid changes to 1). Reorder applied to both the trap AND the normal shutdown path. Verified locally: before fix, 6 orphan sleeps after the test ran; after fix, 0 orphan sleeps. Test still passes 6/6 in ~1s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
462 lines
18 KiB
TypeScript
462 lines
18 KiB
TypeScript
/**
|
|
* Tests for `gbrain extract-conversation-facts` — deterministic parsing,
|
|
* segmenting, rendering, checkpoint encoding, and core wiring contracts.
|
|
*
|
|
* Hermetic via __setChatTransportForTests + __setEmbedTransportForTests
|
|
* stubs so the suite stays offline. Real-LLM extraction quality is the
|
|
* job of test/eval/conversation-extraction-quality.eval.ts (env-gated).
|
|
*
|
|
* Test-isolation invariants (per CLAUDE.md R3+R4):
|
|
* - One PGLite engine per file, created in beforeAll, disposed in afterAll
|
|
* - Per-test state reset via TRUNCATE inside beforeEach (canonical pattern)
|
|
*/
|
|
|
|
import { describe, expect, test, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import {
|
|
__setChatTransportForTests,
|
|
__setEmbedTransportForTests,
|
|
resetGateway,
|
|
type ChatResult,
|
|
} from '../src/core/ai/gateway.ts';
|
|
import {
|
|
parseConversationMessages,
|
|
splitIntoSegments,
|
|
renderSegmentForExtraction,
|
|
runExtractConversationFactsCore,
|
|
extractConversationFactsFingerprint,
|
|
encodeCheckpointEntry,
|
|
decodeCheckpointEntry,
|
|
DEFAULT_SEGMENT_GAP_MINUTES,
|
|
DEFAULT_SEGMENT_MAX_MESSAGES,
|
|
SEGMENT_TEXT_CHAR_LIMIT,
|
|
MAX_PAGE_BODY_BYTES,
|
|
TERMINAL_AUDIT_SOURCE,
|
|
PER_SEGMENT_SOURCE_PREFIX,
|
|
} from '../src/commands/extract-conversation-facts.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture helpers.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function fmt(name: string, date: string, time: string, body: string): string {
|
|
return `**${name}** (${date} ${time}): ${body}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseConversationMessages — PR's 5 cases verbatim.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('parseConversationMessages', () => {
|
|
test('parses a single message line', () => {
|
|
const msgs = parseConversationMessages(fmt('Alice Example', '2024-03-15', '6:07 PM', 'hello'));
|
|
expect(msgs).toHaveLength(1);
|
|
expect(msgs[0].speaker).toBe('Alice Example');
|
|
expect(msgs[0].text).toBe('hello');
|
|
expect(msgs[0].timestamp).toMatch(/^2024-03-15T18:07:00Z$/);
|
|
});
|
|
|
|
test('handles AM/PM and midnight/noon', () => {
|
|
const body = [
|
|
fmt('Bob Demo', '2024-03-15', '12:00 AM', 'midnight'),
|
|
fmt('Bob Demo', '2024-03-15', '12:30 PM', 'noon'),
|
|
].join('\n');
|
|
const msgs = parseConversationMessages(body);
|
|
expect(msgs[0].timestamp).toBe('2024-03-15T00:00:00Z');
|
|
expect(msgs[1].timestamp).toBe('2024-03-15T12:30:00Z');
|
|
});
|
|
|
|
test('treats unmatched lines as continuations of the prior message', () => {
|
|
const body = [
|
|
fmt('Alice Example', '2024-03-15', '9:00 AM', 'first line'),
|
|
'still part of the first message',
|
|
fmt('Bob Demo', '2024-03-15', '9:01 AM', 'separate message'),
|
|
].join('\n');
|
|
const msgs = parseConversationMessages(body);
|
|
expect(msgs).toHaveLength(2);
|
|
expect(msgs[0].text).toBe('first line\nstill part of the first message');
|
|
expect(msgs[1].text).toBe('separate message');
|
|
});
|
|
|
|
test('ignores leading orphan lines (no anchor message yet)', () => {
|
|
const body = ['orphan one', 'orphan two', fmt('Alice Example', '2024-03-15', '9:00 AM', 'real')].join('\n');
|
|
const msgs = parseConversationMessages(body);
|
|
expect(msgs).toHaveLength(1);
|
|
expect(msgs[0].text).toBe('real');
|
|
});
|
|
|
|
test('empty body returns empty array', () => {
|
|
expect(parseConversationMessages('')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// splitIntoSegments — PR's 5 cases verbatim plus tuning regression.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('splitIntoSegments', () => {
|
|
test('cuts on time gap larger than gapMinutes', () => {
|
|
const msgs = parseConversationMessages([
|
|
fmt('Alice Example', '2024-03-15', '9:00 AM', 'a'),
|
|
fmt('Bob Demo', '2024-03-15', '9:05 AM', 'b'),
|
|
// Gap of 90 minutes > default 30 → new segment.
|
|
fmt('Alice Example', '2024-03-15', '10:35 AM', 'c'),
|
|
fmt('Bob Demo', '2024-03-15', '10:36 AM', 'd'),
|
|
].join('\n'));
|
|
const segs = splitIntoSegments(msgs);
|
|
expect(segs).toHaveLength(2);
|
|
expect(segs[0].messages).toHaveLength(2);
|
|
expect(segs[1].messages).toHaveLength(2);
|
|
});
|
|
|
|
test('cuts when segment reaches maxMessages cap', () => {
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
const mm = String(i).padStart(2, '0');
|
|
lines.push(fmt('Alice Example', '2024-03-15', `9:${mm} AM`, `msg ${i}`));
|
|
}
|
|
const msgs = parseConversationMessages(lines.join('\n'));
|
|
const segs = splitIntoSegments(msgs, { maxMessages: 3 });
|
|
// 7 messages / 3 per segment → 2 full + 1 leftover (dropped: <2 messages).
|
|
expect(segs.length).toBeGreaterThanOrEqual(2);
|
|
for (const s of segs) expect(s.messages.length).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
test('drops segments shorter than the minimum', () => {
|
|
const msgs = parseConversationMessages(
|
|
fmt('Alice Example', '2024-03-15', '9:00 AM', 'only one'),
|
|
);
|
|
expect(splitIntoSegments(msgs)).toHaveLength(0);
|
|
});
|
|
|
|
test('participants array preserves first-seen order', () => {
|
|
const msgs = parseConversationMessages([
|
|
fmt('Bob Demo', '2024-03-15', '9:00 AM', 'b1'),
|
|
fmt('Alice Example', '2024-03-15', '9:05 AM', 'a1'),
|
|
fmt('Bob Demo', '2024-03-15', '9:06 AM', 'b2'),
|
|
].join('\n'));
|
|
const segs = splitIntoSegments(msgs);
|
|
expect(segs[0].participants).toEqual(['Bob Demo', 'Alice Example']);
|
|
});
|
|
|
|
test('sinceIso filters out messages older than the watermark', () => {
|
|
const msgs = parseConversationMessages([
|
|
fmt('Alice Example', '2024-03-15', '9:00 AM', 'old'),
|
|
fmt('Bob Demo', '2024-03-15', '9:05 AM', 'old'),
|
|
fmt('Alice Example', '2024-03-16', '9:00 AM', 'new'),
|
|
fmt('Bob Demo', '2024-03-16', '9:05 AM', 'new'),
|
|
].join('\n'));
|
|
const segs = splitIntoSegments(msgs, { sinceIso: '2024-03-15T23:00:00Z' });
|
|
expect(segs).toHaveLength(1);
|
|
expect(segs[0].startIso).toBe('2024-03-16T09:00:00Z');
|
|
});
|
|
|
|
test('tuned defaults: 30/30 (Eng-v2 T5)', () => {
|
|
expect(DEFAULT_SEGMENT_GAP_MINUTES).toBe(30);
|
|
expect(DEFAULT_SEGMENT_MAX_MESSAGES).toBe(30);
|
|
expect(SEGMENT_TEXT_CHAR_LIMIT).toBe(6500);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// renderSegmentForExtraction.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('renderSegmentForExtraction', () => {
|
|
test('prepends topical/temporal context header', () => {
|
|
const msgs = parseConversationMessages([
|
|
fmt('Alice Example', '2024-03-15', '9:00 AM', 'hello'),
|
|
fmt('Bob Demo', '2024-03-15', '9:05 AM', 'hi back'),
|
|
].join('\n'));
|
|
const seg = splitIntoSegments(msgs)[0];
|
|
const text = renderSegmentForExtraction('imessage: Alice Example', seg);
|
|
expect(text).toContain('Page: imessage: Alice Example');
|
|
expect(text).toContain('Conversation between Alice Example and Bob Demo');
|
|
expect(text).toContain('2024-03-15T09:00:00Z');
|
|
expect(text).toContain('2024-03-15T09:05:00Z');
|
|
});
|
|
|
|
test('truncates oversize segments but keeps the header intact', () => {
|
|
const big = Array.from({ length: 500 }, (_, i) => {
|
|
const mm = String(i % 60).padStart(2, '0');
|
|
const hh = String(9 + Math.floor(i / 60)).padStart(2, '0');
|
|
return `**Alice Example** (2024-03-15 ${hh}:${mm} AM): ${'x'.repeat(50)}`;
|
|
}).join('\n');
|
|
const msgs = parseConversationMessages(big);
|
|
const seg = splitIntoSegments(msgs, { maxMessages: 500 })[0];
|
|
const text = renderSegmentForExtraction('big-page', seg);
|
|
expect(text.length).toBeLessThanOrEqual(SEGMENT_TEXT_CHAR_LIMIT + 32);
|
|
expect(text.startsWith('Page: big-page')).toBe(true);
|
|
expect(text).toContain('Conversation between');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fingerprint + checkpoint encoding.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('extractConversationFactsFingerprint (Eng-v2 A3)', () => {
|
|
test('same sourceId yields same fingerprint', () => {
|
|
expect(extractConversationFactsFingerprint({ sourceId: 'default' }))
|
|
.toBe(extractConversationFactsFingerprint({ sourceId: 'default' }));
|
|
});
|
|
|
|
test('different sourceId yields different fingerprint', () => {
|
|
expect(extractConversationFactsFingerprint({ sourceId: 'a' }))
|
|
.not.toBe(extractConversationFactsFingerprint({ sourceId: 'b' }));
|
|
});
|
|
});
|
|
|
|
describe('checkpoint entry encoding', () => {
|
|
test('round-trips sourceId | slug | iso', () => {
|
|
const entry = encodeCheckpointEntry('default', 'conversations/imessage/alice-example', '2024-03-16T08:05:00Z');
|
|
const decoded = decodeCheckpointEntry(entry);
|
|
expect(decoded).toEqual({
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
endIso: '2024-03-16T08:05:00Z',
|
|
});
|
|
});
|
|
|
|
test('decodes null for malformed entries', () => {
|
|
expect(decodeCheckpointEntry('no-pipes-here')).toBeNull();
|
|
expect(decodeCheckpointEntry('only-one|pipe')).toBeNull();
|
|
});
|
|
|
|
test('slug with forward slashes survives encoding (no pipe collision)', () => {
|
|
const entry = encodeCheckpointEntry('src-a', 'conversations/group/2024/march/team-x', '2024-03-16T08:05:00Z');
|
|
const decoded = decodeCheckpointEntry(entry);
|
|
expect(decoded?.slug).toBe('conversations/group/2024/march/team-x');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// runExtractConversationFactsCore — engine-wired contract tests.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SAMPLE_BODY = [
|
|
fmt('Alice Example', '2024-03-15', '9:00 AM', 'Hi, I just signed the offer letter for Acme Corp.'),
|
|
fmt('Bob Demo', '2024-03-15', '9:01 AM', "Congrats! What's the title?"),
|
|
fmt('Alice Example', '2024-03-15', '9:02 AM', 'Staff engineer on the platform team.'),
|
|
fmt('Bob Demo', '2024-03-15', '9:03 AM', 'Nice.'),
|
|
// Big time gap → new segment.
|
|
fmt('Alice Example', '2024-03-16', '8:00 AM', 'Update: I started at Acme Corp this morning.'),
|
|
fmt('Bob Demo', '2024-03-16', '8:05 AM', 'Day one! How is it?'),
|
|
].join('\n');
|
|
|
|
describe('runExtractConversationFactsCore', () => {
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
|
|
// Deterministic chat-transport stub. Records calls + returns one
|
|
// fact per turn. Real-LLM extraction quality is the eval suite's job.
|
|
let callIndex = 0;
|
|
__setChatTransportForTests(async (): Promise<ChatResult> => {
|
|
callIndex++;
|
|
return {
|
|
text: JSON.stringify({
|
|
facts: [{
|
|
fact: `synthetic fact #${callIndex}`,
|
|
kind: 'event',
|
|
entity: 'companies/acme-corp',
|
|
confidence: 1.0,
|
|
notability: 'high',
|
|
}],
|
|
}),
|
|
blocks: [],
|
|
stopReason: 'end',
|
|
usage: {
|
|
input_tokens: 100,
|
|
output_tokens: 50,
|
|
cache_read_tokens: 0,
|
|
cache_creation_tokens: 0,
|
|
},
|
|
model: 'stub:stub',
|
|
providerId: 'stub',
|
|
};
|
|
});
|
|
|
|
// Deterministic embedding stub.
|
|
__setEmbedTransportForTests(
|
|
(async () => ({
|
|
embeddings: [Array.from({ length: 1536 }, () => 0.1)],
|
|
})) as never,
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
__setChatTransportForTests(null);
|
|
__setEmbedTransportForTests(null);
|
|
resetGateway();
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean state per test. Use executeRaw because PGLite uses different
|
|
// truncation semantics than the canonical reset helper.
|
|
await engine.executeRaw(`DELETE FROM facts WHERE source LIKE 'cli:extract-conversation-facts%'`);
|
|
await engine.executeRaw(`DELETE FROM op_checkpoints WHERE op = 'extract-conversation-facts'`);
|
|
await engine.executeRaw(`DELETE FROM pages WHERE slug LIKE 'conversations/%' OR slug LIKE 'people/alice%'`);
|
|
// Set facts.extraction_enabled=true so kill-switch doesn't refuse.
|
|
await engine.setConfig('facts.extraction_enabled', 'true');
|
|
// Seed test pages.
|
|
await engine.putPage('conversations/imessage/alice-example', {
|
|
type: 'conversation',
|
|
title: 'iMessage: Alice Example',
|
|
compiled_truth: SAMPLE_BODY,
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
await engine.putPage('people/alice-example', {
|
|
type: 'person',
|
|
title: 'Alice Example',
|
|
compiled_truth: 'Profile content for Alice Example.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
});
|
|
|
|
test('dry-run reports segmentation without writing facts', async () => {
|
|
const result = await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
dryRun: true,
|
|
sleepMs: 0,
|
|
});
|
|
expect(result.pages_considered).toBe(1);
|
|
expect(result.pages_processed).toBe(1);
|
|
expect(result.facts_inserted).toBe(0);
|
|
expect(result.segments_processed).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('non-conversation pages are skipped', async () => {
|
|
const result = await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'people/alice-example',
|
|
dryRun: true,
|
|
sleepMs: 0,
|
|
});
|
|
// pages_considered counts only pages whose type matches the allowlist.
|
|
expect(result.pages_considered).toBe(0);
|
|
});
|
|
|
|
test('sinceIso filters already-processed history', async () => {
|
|
const result = await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
dryRun: true,
|
|
sleepMs: 0,
|
|
sinceIso: '2099-01-01T00:00:00Z',
|
|
});
|
|
expect(result.pages_processed).toBe(0);
|
|
expect(result.pages_skipped).toBe(1);
|
|
});
|
|
|
|
test('writes facts with per-segment source_session AND terminal audit row (E16)', async () => {
|
|
const result = await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
sleepMs: 0,
|
|
});
|
|
expect(result.pages_processed).toBe(1);
|
|
expect(result.facts_inserted).toBeGreaterThan(0);
|
|
|
|
// Per-segment facts present.
|
|
const perSegFacts = await engine.executeRaw<{ count: string | number }>(
|
|
`SELECT COUNT(*) AS count FROM facts WHERE source = $1 AND source_session = $2`,
|
|
[PER_SEGMENT_SOURCE_PREFIX, `${PER_SEGMENT_SOURCE_PREFIX}:conversations/imessage/alice-example`],
|
|
);
|
|
expect(Number(perSegFacts[0]?.count ?? 0)).toBeGreaterThan(0);
|
|
|
|
// Terminal audit row present.
|
|
const terminalRows = await engine.executeRaw<{ count: string | number }>(
|
|
`SELECT COUNT(*) AS count FROM facts WHERE source = $1 AND source_session = $2`,
|
|
[TERMINAL_AUDIT_SOURCE, `${TERMINAL_AUDIT_SOURCE}:conversations/imessage/alice-example`],
|
|
);
|
|
expect(Number(terminalRows[0]?.count ?? 0)).toBe(1);
|
|
});
|
|
|
|
test('row_num accumulator: segment 2 facts start after segment 1 (Codex C1)', async () => {
|
|
await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
sleepMs: 0,
|
|
});
|
|
const rows = await engine.executeRaw<{ row_num: number }>(
|
|
`SELECT row_num FROM facts
|
|
WHERE source = $1 AND source_markdown_slug = $2
|
|
ORDER BY row_num ASC`,
|
|
[PER_SEGMENT_SOURCE_PREFIX, 'conversations/imessage/alice-example'],
|
|
);
|
|
// Each row_num must be unique (no per-segment collision on row 0).
|
|
const nums = rows.map((r) => Number(r.row_num));
|
|
expect(new Set(nums).size).toBe(nums.length);
|
|
// Strictly monotonic + zero-based.
|
|
for (let i = 0; i < nums.length; i++) {
|
|
expect(nums[i]).toBe(i);
|
|
}
|
|
});
|
|
|
|
test('--force clears resume entry, allowing re-run', async () => {
|
|
const first = await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
sleepMs: 0,
|
|
});
|
|
expect(first.pages_processed).toBe(1);
|
|
// Re-run without force: no new segments (sinceIso > newest segment endIso).
|
|
const second = await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
sleepMs: 0,
|
|
});
|
|
expect(second.pages_skipped).toBe(1);
|
|
// Re-run with force: re-processes.
|
|
const third = await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
sleepMs: 0,
|
|
force: true,
|
|
});
|
|
expect(third.pages_processed).toBe(1);
|
|
expect(third.segments_processed).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('honors facts.extraction_enabled kill-switch (F2)', async () => {
|
|
await engine.setConfig('facts.extraction_enabled', 'false');
|
|
await expect(
|
|
runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
sleepMs: 0,
|
|
}),
|
|
).rejects.toThrow(/extraction_enabled=false/);
|
|
});
|
|
|
|
test('--override-disabled bypasses kill-switch', async () => {
|
|
await engine.setConfig('facts.extraction_enabled', 'false');
|
|
const result = await runExtractConversationFactsCore(engine, {
|
|
sourceId: 'default',
|
|
slug: 'conversations/imessage/alice-example',
|
|
sleepMs: 0,
|
|
overrideDisabled: true,
|
|
});
|
|
expect(result.pages_processed).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Body cap (Eng A2 / E17) — pin the cap constant; integration via reads
|
|
// in seeded huge pages would require >25MB fixture, not viable in unit suite.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('body cap constant (Eng A2)', () => {
|
|
test('MAX_PAGE_BODY_BYTES is 25MB', () => {
|
|
expect(MAX_PAGE_BODY_BYTES).toBe(25 * 1024 * 1024);
|
|
});
|
|
});
|