Files
gbrain/test/e2e/extraction-review-postgres.test.ts
T
56aac51a08 feat(extract): quarantine lane for auto-extracted entities from untrusted input (#160) (#3458)
* 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>
2026-07-27 18:01:35 -07:00

111 lines
5.6 KiB
TypeScript

/**
* Extraction quarantine lane (issue #160) — LIVE Postgres parity.
*
* The PGLite coverage lives in test/extraction-review.test.ts; this file
* re-runs the SQL-touching pieces on a real Postgres so the shared
* `unverifiedExtractionFragment` predicate, the engine method
* `getUnverifiedExtractionPageIds`, the source-boost guard inside
* `buildSourceFactorCase`, and the review-op raw SQL are proven on both
* engines (PGLite can hide postgres.js-specific behavior).
*
* Gated by DATABASE_URL via hasDatabase(); skips cleanly when unset.
*
* Run: DATABASE_URL=... bun test test/e2e/extraction-review-postgres.test.ts
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import type { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { hasDatabase, setupDB, teardownDB } from './helpers.ts';
import { enrichEntity } from '../../src/core/enrichment-service.ts';
import { isUnverifiedExtraction, STATUS_VERIFIED, EXTRACTION_STATUS_KEY } from '../../src/core/extraction-review.ts';
import { operationsByName, type OperationContext } from '../../src/core/operations.ts';
const RUN = hasDatabase();
const d = RUN ? describe : describe.skip;
let engine: PostgresEngine;
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;
}
d('extraction quarantine lane (live Postgres)', () => {
beforeAll(async () => {
engine = await setupDB();
}, 60_000);
afterAll(async () => {
await teardownDB();
}, 60_000);
test('untrusted enrichEntity → markers; getUnverifiedExtractionPageIds sees them', async () => {
await enrichEntity(engine, { entityName: 'Pg Fake', entityType: 'person', context: 'c', sourceSlug: 's' });
await enrichEntity(engine, { entityName: 'Pg Real', entityType: 'person', context: 'c', sourceSlug: 's' }, { trusted: true });
const fake = await engine.getPage('people/pg-fake');
const real = await engine.getPage('people/pg-real');
expect(isUnverifiedExtraction(fake!.frontmatter)).toBe(true);
expect(isUnverifiedExtraction(real!.frontmatter)).toBe(false);
const set = await engine.getUnverifiedExtractionPageIds([fake!.id, real!.id]);
expect(set.has(fake!.id)).toBe(true);
expect(set.has(real!.id)).toBe(false);
});
test('SQL source-boost guard: unverified stub loses the people/ 1.2x in searchKeyword', async () => {
await engine.upsertChunks('people/pg-fake', [{ chunk_index: 0, chunk_text: 'flurbo synergy report alpha', chunk_source: 'compiled_truth', token_count: 4 }]);
await engine.upsertChunks('people/pg-real', [{ chunk_index: 0, chunk_text: 'flurbo synergy report bravo', chunk_source: 'compiled_truth', token_count: 4 }]);
const rows = await engine.searchKeyword('flurbo', { limit: 10 });
const fake = rows.find((r) => r.slug === 'people/pg-fake')!;
const real = rows.find((r) => r.slug === 'people/pg-real')!;
expect(fake).toBeDefined();
expect(real).toBeDefined();
// Same base ts_rank; only the verified page carries the 1.2 factor.
expect(real.score / fake.score).toBeCloseTo(1.2, 5);
});
test('vector arm: unverified stub gets source factor 1.0 in searchVector re-rank', async () => {
// The 1.2x people/ factor multiplies raw_score inside the scored CTE,
// pre-LIMIT — the guard column projected in hnsw_candidates must zero it
// out for unverified stubs. Identical basis embeddings → identical
// cosine → the score ratio IS the factor. (1536-dim basis vectors match
// the shared e2e schema, same as test/e2e/engine-parity.test.ts.)
const basis = new Float32Array(1536);
basis[7] = 1.0;
await engine.upsertChunks('people/pg-fake', [{ chunk_index: 1, chunk_text: 'vec alpha', chunk_source: 'compiled_truth', embedding: basis, token_count: 2 }]);
await engine.upsertChunks('people/pg-real', [{ chunk_index: 1, chunk_text: 'vec bravo', chunk_source: 'compiled_truth', embedding: basis, token_count: 2 }]);
const rows = await engine.searchVector(basis, { limit: 10 });
const fake = rows.find((r) => r.slug === 'people/pg-fake')!;
const real = rows.find((r) => r.slug === 'people/pg-real')!;
expect(fake).toBeDefined();
expect(real).toBeDefined();
expect(real.score / fake.score).toBeCloseTo(1.2, 5);
});
test('extraction_pending + extraction_review promote/reject run on Postgres', async () => {
const pending = (await operationsByName['extraction_pending']!.handler(ctx(), {})) as {
pending: Array<{ slug: string }>;
};
expect(pending.pending.map((r) => r.slug)).toContain('people/pg-fake');
const out = (await operationsByName['extraction_review']!.handler(ctx({ remote: false }), {
action: 'promote', slugs: ['people/pg-fake'],
})) as { results: Array<{ slug: string; status: string }> };
expect(out.results[0].status).toBe('promoted');
const promoted = await engine.getPage('people/pg-fake');
expect(promoted!.frontmatter[EXTRACTION_STATUS_KEY]).toBe(STATUS_VERIFIED);
await enrichEntity(engine, { entityName: 'Pg Reject', entityType: 'person', context: 'c', sourceSlug: 's' });
const rej = (await operationsByName['extraction_review']!.handler(ctx({ remote: false }), {
action: 'reject', slugs: ['people/pg-reject'],
})) as { results: Array<{ slug: string; status: string }> };
expect(rej.results[0].status).toBe('rejected');
expect(await engine.getPage('people/pg-reject')).toBeNull();
});
});