mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +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>
111 lines
5.6 KiB
TypeScript
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();
|
|
});
|
|
});
|