mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* feat(extract): quarantine lane for auto-extracted entities from untrusted input (#160) extractAndEnrich regex-extracts entity names from arbitrary ingested text and creates people/ + companies/ stub pages. Those writes are now trust- gated end to end: - src/core/extraction-review.ts: new marker module (sibling of quarantine.ts / embed-skip.ts, frontmatter-key pattern, no migration). Untrusted-input stubs carry `provenance: auto-extracted` + `status: unverified`; the shared unverifiedExtractionFragment() is the single SQL source of truth for every consumer. - enrichment-service: enrichEntity/enrichEntities/extractAndEnrich take EnrichmentTrustOptions; only an explicit trusted:true writes authoritative pages (fail-closed, mirrors the OperationContext.remote invariant). Also threads sourceId through the write path. - retrieval: unverified stubs rank as ordinary content — skipped by the compiled-truth fusion boost (stampUnverifiedExtractions pre-fusion on all three hybrid paths + keyword-only opt-out) and by the people// companies/ namespace source-boost (guard inside buildSourceFactorCase, shared by both engines' search SQL). Results carry `unverified: true`. New engine method getUnverifiedExtractionPageIds in BOTH engines. - ops (contract-first): extract_entities (direct write only for ctx.remote === false + --trusted-extraction; everything else quarantines), extraction_pending (read, source-scoped list), extraction_review (owner-only batch promote/reject; promote flips status to verified keeping provenance for audit, reject soft-deletes). - doctor: unverified_extractions check warns on stubs older than N days (default 7) with the exact review commands. Tests: test/extraction-review.test.ts (PGLite: fail-closed matrix incl. remote-unset, fusion boost skip, review queue, doctor, hostile-transcript e2e proving fake entities land quarantined and rank below a verified page of equal lexical relevance) + test/e2e/extraction-review-postgres.test.ts (live Postgres parity, verified against pgvector:pg16). sql-ranking expectations updated to current state. Docs: KEY_FILES + RETRIEVAL + llms rebuild. Closes #160 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(extract): close vector-arm source-boost gap + harden extract_entities (#160 review round) Adversarial review of the quarantine lane found the people//companies/ 1.2x source factor still applied to unverified stubs inside searchVector's pre-LIMIT re-rank (a different multiplier from the fusion-level 2.0x the lane already cancels — and applied early enough to evict legitimate pages from the candidate pool, which nothing downstream can restore). - buildSourceFactorCase gains an optional unverifiedGuardColumn for the bare-slug re-rank form; both engines' hnsw_candidates CTEs now project the guard predicate as `unverified_stub` and the factor CASE checks it first. Wrong "fusion covers the vector arm" comment corrected. - extract_entities resource guards: 200k-char input cap (loud reject), 200-entity cap surfaced as `truncated` + `entities_found`; the library extractAndEnrich gets the same default cap. (OperationContext has no abort signal field — caps are the bound.) - extraction_review promote is now a targeted JSONB-merge UPDATE instead of putPage, so non-carried columns (page_kind, content_hash) can't be reset by the upsert. - extraction_pending applies buildVisibilityClause (archived-source stubs no longer list). - Wording: op description + module header now state the marker-strip assumption plainly (markers are ordinary frontmatter; the boundary against wholesale rewrite is put_page write authz) and document the CREATE-only scope of the lane. Tests: vector-arm factor-1.0 pinned on BOTH engines (PGLite unit + live Postgres e2e, identical basis embeddings → score ratio is the factor); resource-guard test (oversize reject + 300-entity flood capped at 200); guard-column form pinned in the buildSourceFactorCase unit test. search/ suite (340), sql-ranking, searchvector-maxpool, title-retrieval- arm, rrf-source-key, doctor, ops, cli suites all green; JSONB guards clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
447 lines
21 KiB
TypeScript
447 lines
21 KiB
TypeScript
/**
|
|
* Extraction quarantine lane (issue #160).
|
|
*
|
|
* `extractAndEnrich` regex-extracts entity names from arbitrary ingested text
|
|
* and creates people/ + companies/ stub pages. These tests pin the lane
|
|
* end-to-end:
|
|
* - fail-closed trust: only an explicit `trusted: true` (which the op layer
|
|
* only grants for ctx.remote === false AND --trusted-extraction) writes
|
|
* authoritative pages; undefined/false/remote → quarantine markers.
|
|
* - unverified stubs are excluded from authoritative retrieval boosts
|
|
* (compiled-truth fusion boost + the SQL namespace source-boost) and
|
|
* carry `unverified: true` in search-result metadata.
|
|
* - review queue: extraction_pending lists; extraction_review promotes
|
|
* (status → verified) / rejects (soft-delete) in batch, owner-only.
|
|
* - doctor nudge: unverified_extractions counts stale stubs.
|
|
*
|
|
* Hermetic via PGLite (both engines share the SQL through
|
|
* unverifiedExtractionFragment + the same literal method SQL; postgres runs
|
|
* via the DATABASE_URL-gated e2e lane).
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
|
import {
|
|
quarantineMarkers,
|
|
isUnverifiedExtraction,
|
|
unverifiedExtractionFragment,
|
|
EXTRACTION_STATUS_KEY,
|
|
STATUS_UNVERIFIED,
|
|
STATUS_VERIFIED,
|
|
PROVENANCE_AUTO_EXTRACTED,
|
|
} from '../src/core/extraction-review.ts';
|
|
import { enrichEntity, extractAndEnrich } from '../src/core/enrichment-service.ts';
|
|
import { rrfFusion, hybridSearch } from '../src/core/search/hybrid.ts';
|
|
import { buildSourceFactorCase } from '../src/core/search/sql-ranking.ts';
|
|
import { operationsByName, OperationError, type OperationContext } from '../src/core/operations.ts';
|
|
import { checkUnverifiedExtractions } from '../src/commands/doctor.ts';
|
|
import { categorizeCheck } from '../src/core/doctor-categories.ts';
|
|
import type { SearchResult } from '../src/core/types.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
function basisEmbedding(idx: number, dim = 1536): Float32Array {
|
|
const emb = new Float32Array(dim);
|
|
emb[idx % dim] = 1.0;
|
|
return emb;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// Deterministic no-embedding-provider path: configure the gateway with NO
|
|
// auth env so hybridSearch never attempts a real embedding call, even on a
|
|
// dev machine with provider keys in process.env. Pins the vector dim too
|
|
// (shard-order defense, same class as doctor-hidden-by-search-policy).
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: {},
|
|
});
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
resetGateway();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM content_chunks');
|
|
await engine.executeRaw('DELETE FROM links');
|
|
await engine.executeRaw('DELETE FROM timeline_entries');
|
|
await engine.executeRaw('DELETE FROM pages');
|
|
});
|
|
|
|
function ctx(over: Partial<OperationContext> = {}): OperationContext {
|
|
return {
|
|
engine,
|
|
config: {} as OperationContext['config'],
|
|
logger: { info() {}, warn() {}, error() {}, debug() {} } as unknown as OperationContext['logger'],
|
|
dryRun: false,
|
|
remote: true,
|
|
sourceId: 'default',
|
|
...over,
|
|
} as OperationContext;
|
|
}
|
|
|
|
/** ctx with `remote` deleted entirely — the type-bypass case the fail-closed
|
|
* invariant exists for ("anything not strictly false is untrusted"). */
|
|
function ctxNoRemote(): OperationContext {
|
|
const c = ctx() as unknown as Record<string, unknown>;
|
|
delete c.remote;
|
|
return c as unknown as OperationContext;
|
|
}
|
|
|
|
const extract_entities = operationsByName['extract_entities']!;
|
|
const extraction_pending = operationsByName['extraction_pending']!;
|
|
const extraction_review = operationsByName['extraction_review']!;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Marker module (pure)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('extraction-review markers', () => {
|
|
test('quarantineMarkers → provenance + status pair', () => {
|
|
expect(quarantineMarkers()).toEqual({ provenance: PROVENANCE_AUTO_EXTRACTED, status: STATUS_UNVERIFIED });
|
|
});
|
|
|
|
test('isUnverifiedExtraction requires BOTH markers', () => {
|
|
expect(isUnverifiedExtraction(quarantineMarkers())).toBe(true);
|
|
expect(isUnverifiedExtraction({ status: 'unverified' })).toBe(false);
|
|
expect(isUnverifiedExtraction({ provenance: 'auto-extracted' })).toBe(false);
|
|
expect(isUnverifiedExtraction({ provenance: 'auto-extracted', status: 'verified' })).toBe(false);
|
|
expect(isUnverifiedExtraction({ status: 'unverified', provenance: 'user' })).toBe(false);
|
|
expect(isUnverifiedExtraction(null)).toBe(false);
|
|
expect(isUnverifiedExtraction(undefined)).toBe(false);
|
|
});
|
|
|
|
test('SQL fragment references both keys on the given alias', () => {
|
|
const frag = unverifiedExtractionFragment('p');
|
|
expect(frag).toContain("p.frontmatter");
|
|
expect(frag).toContain(PROVENANCE_AUTO_EXTRACTED);
|
|
expect(frag).toContain(STATUS_UNVERIFIED);
|
|
});
|
|
|
|
test('buildSourceFactorCase guards unverified stubs in both forms', () => {
|
|
const qualified = buildSourceFactorCase('p.slug', { 'people/': 1.2 }, 'low');
|
|
expect(qualified).toContain(unverifiedExtractionFragment('p'));
|
|
expect(qualified.indexOf(unverifiedExtractionFragment('p'))).toBeLessThan(qualified.indexOf('people/'));
|
|
// Vector re-rank form: bare slug column + pre-computed guard column
|
|
// (projected in hnsw_candidates) — the guard WHEN must come first.
|
|
const guarded = buildSourceFactorCase('slug', { 'people/': 1.2 }, 'low', 'unverified_stub');
|
|
expect(guarded).toContain('CASE WHEN unverified_stub THEN 1.0');
|
|
expect(guarded.indexOf('unverified_stub')).toBeLessThan(guarded.indexOf('people/'));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fusion boost skip (pure)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('rrfFusion compiled-truth boost skip', () => {
|
|
function result(slug: string, over: Partial<SearchResult> = {}): SearchResult {
|
|
return {
|
|
slug,
|
|
page_id: over.page_id ?? 1,
|
|
title: slug,
|
|
type: 'person',
|
|
chunk_text: 'x',
|
|
chunk_source: 'compiled_truth',
|
|
chunk_id: over.chunk_id ?? 1,
|
|
chunk_index: 0,
|
|
score: 1,
|
|
stale: false,
|
|
...over,
|
|
} as SearchResult;
|
|
}
|
|
|
|
test('unverified compiled_truth chunk does NOT get the 2x boost', () => {
|
|
const verified = result('people/real', { page_id: 1, chunk_id: 1 });
|
|
const unverified = result('people/fake', { page_id: 2, chunk_id: 2, unverified: true });
|
|
// Two single-result lists at the same rank → identical raw RRF scores.
|
|
const fused = rrfFusion([[verified], [unverified]], 60, true);
|
|
const v = fused.find((r) => r.slug === 'people/real')!;
|
|
const u = fused.find((r) => r.slug === 'people/fake')!;
|
|
expect(u.unverified).toBe(true);
|
|
// Same normalized base; verified gets 2.0x, unverified stays 1.0x.
|
|
expect(v.score).toBeCloseTo(u.score * 2.0, 10);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Enrichment write path (PGLite)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('enrichEntity trust lane', () => {
|
|
test('default (opts omitted) → fail-closed quarantine markers', async () => {
|
|
const r = await enrichEntity(engine, {
|
|
entityName: 'Mallory Fake',
|
|
entityType: 'person',
|
|
context: 'injected sentence',
|
|
sourceSlug: 'inbox/hostile-email',
|
|
});
|
|
expect(r.action).toBe('created');
|
|
expect(r.quarantined).toBe(true);
|
|
const page = await engine.getPage('people/mallory-fake');
|
|
expect(isUnverifiedExtraction(page!.frontmatter)).toBe(true);
|
|
expect(page!.frontmatter[EXTRACTION_STATUS_KEY]).toBe(STATUS_UNVERIFIED);
|
|
});
|
|
|
|
test('trusted: true → direct authoritative write, no markers', async () => {
|
|
const r = await enrichEntity(engine, {
|
|
entityName: 'Alice Example',
|
|
entityType: 'person',
|
|
context: 'my own notes',
|
|
sourceSlug: 'notes/daily',
|
|
}, { trusted: true });
|
|
expect(r.action).toBe('created');
|
|
expect(r.quarantined).toBeUndefined();
|
|
const page = await engine.getPage('people/alice-example');
|
|
expect(isUnverifiedExtraction(page!.frontmatter)).toBe(false);
|
|
expect(page!.frontmatter[EXTRACTION_STATUS_KEY]).toBeUndefined();
|
|
});
|
|
|
|
test('trusted: false explicitly → quarantine markers', async () => {
|
|
await enrichEntity(engine, {
|
|
entityName: 'Widget Co Corp',
|
|
entityType: 'company',
|
|
context: 'ctx',
|
|
sourceSlug: 'inbox/x',
|
|
}, { trusted: false });
|
|
const page = await engine.getPage('companies/widget-co-corp');
|
|
expect(isUnverifiedExtraction(page!.frontmatter)).toBe(true);
|
|
});
|
|
|
|
test('vector arm: unverified stub gets source factor 1.0, not the people/ 1.2x', async () => {
|
|
// The 1.2x namespace factor is applied INSIDE searchVector's re-rank SQL,
|
|
// pre-LIMIT — an unguarded stub would outrank AND could evict legitimate
|
|
// pages from the candidate pool before fusion ever sees them. Identical
|
|
// basis embeddings → identical cosine → the score ratio IS the factor.
|
|
await enrichEntity(engine, { entityName: 'Vec Fake', entityType: 'person', context: 'c', sourceSlug: 's' });
|
|
await enrichEntity(engine, { entityName: 'Vec Real', entityType: 'person', context: 'c', sourceSlug: 's' }, { trusted: true });
|
|
const e = basisEmbedding(7);
|
|
await engine.upsertChunks('people/vec-fake', [{ chunk_index: 0, chunk_text: 'vector text alpha', chunk_source: 'compiled_truth', embedding: e, token_count: 3 }]);
|
|
await engine.upsertChunks('people/vec-real', [{ chunk_index: 0, chunk_text: 'vector text bravo', chunk_source: 'compiled_truth', embedding: e, token_count: 3 }]);
|
|
const rows = await engine.searchVector(e, { limit: 10 });
|
|
const fake = rows.find((r) => r.slug === 'people/vec-fake')!;
|
|
const real = rows.find((r) => r.slug === 'people/vec-real')!;
|
|
expect(fake).toBeDefined();
|
|
expect(real).toBeDefined();
|
|
expect(real.score / fake.score).toBeCloseTo(1.2, 5);
|
|
});
|
|
|
|
test('getUnverifiedExtractionPageIds returns only marked pages', async () => {
|
|
await enrichEntity(engine, { entityName: 'Fake Guy', entityType: 'person', context: 'c', sourceSlug: 's' });
|
|
await enrichEntity(engine, { entityName: 'Real Guy', entityType: 'person', context: 'c', sourceSlug: 's' }, { trusted: true });
|
|
const fake = await engine.getPage('people/fake-guy');
|
|
const real = await engine.getPage('people/real-guy');
|
|
const set = await engine.getUnverifiedExtractionPageIds([fake!.id, real!.id]);
|
|
expect(set.has(fake!.id)).toBe(true);
|
|
expect(set.has(real!.id)).toBe(false);
|
|
expect((await engine.getUnverifiedExtractionPageIds([])).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ops: trust-boundary matrix + review queue
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('extract_entities op trust boundary', () => {
|
|
const TEXT = 'I had lunch with Bobby Injected today. He said Evil Widgets Inc is pivoting.';
|
|
|
|
test('remote: true → quarantined even WITH trusted_extraction flag', async () => {
|
|
const out = (await extract_entities.handler(ctx({ remote: true }), {
|
|
text: TEXT, source_slug: 'inbox/mail', trusted_extraction: true,
|
|
})) as { trusted: boolean; quarantined: number; count: number };
|
|
expect(out.trusted).toBe(false);
|
|
expect(out.count).toBeGreaterThan(0);
|
|
expect(out.quarantined).toBe(out.count);
|
|
const page = await engine.getPage('people/bobby-injected');
|
|
expect(isUnverifiedExtraction(page!.frontmatter)).toBe(true);
|
|
});
|
|
|
|
test('remote UNSET (type bypass) → fail-closed quarantine', async () => {
|
|
const out = (await extract_entities.handler(ctxNoRemote(), {
|
|
text: TEXT, source_slug: 'inbox/mail', trusted_extraction: true,
|
|
})) as { trusted: boolean; quarantined: number };
|
|
expect(out.trusted).toBe(false);
|
|
expect(out.quarantined).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('remote: false WITHOUT flag → still quarantined (explicit opt-in required)', async () => {
|
|
const out = (await extract_entities.handler(ctx({ remote: false }), {
|
|
text: TEXT, source_slug: 'inbox/mail',
|
|
})) as { trusted: boolean; quarantined: number };
|
|
expect(out.trusted).toBe(false);
|
|
expect(out.quarantined).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('resource guards: oversize text rejected; entity flood capped + surfaced', async () => {
|
|
// Oversize input → loud invalid_params, nothing written.
|
|
await expect(extract_entities.handler(ctx(), {
|
|
text: 'A'.repeat(200_001), source_slug: 'inbox/big',
|
|
})).rejects.toBeInstanceOf(OperationError);
|
|
// 300 distinct name-shaped tokens → capped at 200, truncated surfaced.
|
|
// 300 distinct two-word names (letters only — the extractor regex is
|
|
// [A-Z][a-z]+ per word, digits would break the match).
|
|
const flood = Array.from({ length: 300 }, (_, i) =>
|
|
`Flood Name${String.fromCharCode(97 + (i % 26))}${String.fromCharCode(97 + Math.floor(i / 26))}`,
|
|
).join('. ');
|
|
const out = (await extract_entities.handler(ctx(), { text: flood, source_slug: 'inbox/flood' })) as {
|
|
count: number; entities_found: number; truncated: boolean;
|
|
};
|
|
expect(out.entities_found).toBeGreaterThan(200);
|
|
expect(out.count).toBe(200);
|
|
expect(out.truncated).toBe(true);
|
|
}, 120_000);
|
|
|
|
test('remote: false WITH --trusted-extraction → direct authoritative write', async () => {
|
|
const out = (await extract_entities.handler(ctx({ remote: false }), {
|
|
text: TEXT, source_slug: 'notes/mine', trusted_extraction: true,
|
|
})) as { trusted: boolean; quarantined: number; count: number };
|
|
expect(out.trusted).toBe(true);
|
|
expect(out.quarantined).toBe(0);
|
|
const page = await engine.getPage('people/bobby-injected');
|
|
expect(isUnverifiedExtraction(page!.frontmatter)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('extraction_pending + extraction_review', () => {
|
|
async function seedStub(name: string): Promise<string> {
|
|
const r = await enrichEntity(engine, { entityName: name, entityType: 'person', context: 'c', sourceSlug: 'inbox/x' });
|
|
return r.slug;
|
|
}
|
|
|
|
test('pending lists unverified stubs; promoted/rejected drop out', async () => {
|
|
const a = await seedStub('Fake Aa');
|
|
const b = await seedStub('Fake Bb');
|
|
await enrichEntity(engine, { entityName: 'Real Cc', entityType: 'person', context: 'c', sourceSlug: 's' }, { trusted: true });
|
|
|
|
const before = (await extraction_pending.handler(ctx(), {})) as { count: number; pending: Array<{ slug: string }> };
|
|
expect(before.pending.map((r) => r.slug).sort()).toEqual([a, b].sort());
|
|
|
|
const out = (await extraction_review.handler(ctx({ remote: false }), {
|
|
action: 'promote', slugs: [a],
|
|
})) as { results: Array<{ slug: string; status: string }> };
|
|
expect(out.results).toEqual([{ slug: a, status: 'promoted' }]);
|
|
|
|
const promoted = await engine.getPage(a);
|
|
expect(promoted!.frontmatter[EXTRACTION_STATUS_KEY]).toBe(STATUS_VERIFIED);
|
|
// provenance survives as the audit trail.
|
|
expect(promoted!.frontmatter.provenance).toBe(PROVENANCE_AUTO_EXTRACTED);
|
|
expect(isUnverifiedExtraction(promoted!.frontmatter)).toBe(false);
|
|
|
|
const rej = (await extraction_review.handler(ctx({ remote: false }), {
|
|
action: 'reject', slugs: b, // CLI string form
|
|
})) as { results: Array<{ slug: string; status: string }> };
|
|
expect(rej.results).toEqual([{ slug: b, status: 'rejected' }]);
|
|
expect(await engine.getPage(b)).toBeNull(); // soft-deleted → hidden
|
|
|
|
const after = (await extraction_pending.handler(ctx(), {})) as { count: number };
|
|
expect(after.count).toBe(0);
|
|
});
|
|
|
|
test('batch promote is batch-friendly and reports per-slug statuses', async () => {
|
|
const a = await seedStub('Fake Dd');
|
|
const b = await seedStub('Fake Ee');
|
|
await enrichEntity(engine, { entityName: 'Real Ff', entityType: 'person', context: 'c', sourceSlug: 's' }, { trusted: true });
|
|
const out = (await extraction_review.handler(ctx({ remote: false }), {
|
|
action: 'promote', slugs: [a, b, 'people/real-ff', 'people/missing'],
|
|
})) as { results: Array<{ slug: string; status: string }> };
|
|
expect(out.results.map((r) => r.status)).toEqual(['promoted', 'promoted', 'not_unverified', 'not_found']);
|
|
});
|
|
|
|
test('extraction_review is owner-only: remote and unset-trust callers are refused', async () => {
|
|
const a = await seedStub('Fake Gg');
|
|
await expect(extraction_review.handler(ctx({ remote: true }), { action: 'promote', slugs: [a] }))
|
|
.rejects.toBeInstanceOf(OperationError);
|
|
await expect(extraction_review.handler(ctxNoRemote(), { action: 'promote', slugs: [a] }))
|
|
.rejects.toBeInstanceOf(OperationError);
|
|
// and it is not exposed over HTTP MCP at all
|
|
expect(extraction_review.localOnly).toBe(true);
|
|
// stub untouched
|
|
expect(isUnverifiedExtraction((await engine.getPage(a))!.frontmatter)).toBe(true);
|
|
});
|
|
|
|
test('invalid action / empty slugs → invalid_params', async () => {
|
|
await expect(extraction_review.handler(ctx({ remote: false }), { action: 'bless', slugs: ['x'] }))
|
|
.rejects.toBeInstanceOf(OperationError);
|
|
await expect(extraction_review.handler(ctx({ remote: false }), { action: 'promote', slugs: [] }))
|
|
.rejects.toBeInstanceOf(OperationError);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Doctor nudge
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('unverified_extractions doctor check', () => {
|
|
test('fresh stubs → ok; stale stubs → warn with review commands', async () => {
|
|
await enrichEntity(engine, { entityName: 'Fake Hh', entityType: 'person', context: 'c', sourceSlug: 's' });
|
|
const fresh = await checkUnverifiedExtractions(engine);
|
|
expect(fresh.status).toBe('ok');
|
|
|
|
await engine.executeRaw(`UPDATE pages SET created_at = now() - interval '30 days' WHERE slug = 'people/fake-hh'`);
|
|
const stale = await checkUnverifiedExtractions(engine, { days: 7 });
|
|
expect(stale.status).toBe('warn');
|
|
expect(stale.message).toContain('extraction-pending');
|
|
expect(stale.message).toContain('extraction-review');
|
|
expect((stale.details as { count: number }).count).toBe(1);
|
|
});
|
|
|
|
test('categorized as a brain check', () => {
|
|
expect(categorizeCheck('unverified_extractions')).toBe('brain');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// End-to-end: hostile transcript → quarantined stubs, NOT boosted in search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('e2e: hostile transcript', () => {
|
|
test('fake entities land quarantined and rank without entity authority', async () => {
|
|
// 1. Hostile transcript arrives through an agent-facing (remote) caller.
|
|
const transcript =
|
|
'Meeting notes. I had lunch with Zorbulon Fakeperson today. ' +
|
|
'He mentioned the zorbulon pivot is confirmed.';
|
|
const out = (await extract_entities.handler(ctx({ remote: true }), {
|
|
text: transcript, source_slug: 'meetings/2026-04-03', trusted_extraction: true,
|
|
})) as { trusted: boolean; quarantined: number };
|
|
expect(out.trusted).toBe(false);
|
|
expect(out.quarantined).toBeGreaterThan(0);
|
|
|
|
const stub = await engine.getPage('people/zorbulon-fakeperson');
|
|
expect(stub).not.toBeNull();
|
|
expect(isUnverifiedExtraction(stub!.frontmatter)).toBe(true);
|
|
|
|
// 2. Owner-authored control page with the same lexical relevance.
|
|
await engine.putPage('people/zorbulon-realperson', {
|
|
type: 'person', title: 'Zorbulon Realperson', compiled_truth: 'zorbulon notes', timeline: '', frontmatter: {},
|
|
});
|
|
const real = await engine.getPage('people/zorbulon-realperson');
|
|
|
|
// 3. Chunk both with equal lexical relevance (distinct texts — identical
|
|
// ones would be Jaccard-deduped). Enrichment stubs are chunked by the
|
|
// normal reindex/import pipeline later; seed what it would write.
|
|
await engine.upsertChunks(stub!.slug, [{ chunk_index: 0, chunk_text: 'zorbulon pivot details from the injected meeting', chunk_source: 'compiled_truth', token_count: 7 }]);
|
|
await engine.upsertChunks(real!.slug, [{ chunk_index: 0, chunk_text: 'zorbulon launch update in my own written notes', chunk_source: 'compiled_truth', token_count: 7 }]);
|
|
|
|
// 4. Search. No embedding provider configured → keyword(+title) fusion path.
|
|
const results = await hybridSearch(engine, 'zorbulon', { limit: 10 });
|
|
const fake = results.find((r) => r.slug === stub!.slug);
|
|
const legit = results.find((r) => r.slug === real!.slug);
|
|
expect(fake).toBeDefined();
|
|
expect(legit).toBeDefined();
|
|
|
|
// Clearly marked in search-result metadata…
|
|
expect(fake!.unverified).toBe(true);
|
|
expect(legit!.unverified).toBeUndefined();
|
|
// …and stripped of entity authority: the verified page outranks the
|
|
// injected stub despite identical chunk text (2x compiled-truth boost +
|
|
// people/ source-boost apply only to the verified page).
|
|
expect(legit!.score).toBeGreaterThan(fake!.score);
|
|
});
|
|
});
|