mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +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>
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
buildSourceFactorCase,
|
|
buildHardExcludeClause,
|
|
buildVisibilityClause,
|
|
escapeLikePattern as topLevelEscapeLikePattern,
|
|
__test__,
|
|
} from '../src/core/search/sql-ranking.ts';
|
|
import { unverifiedExtractionFragment } from '../src/core/extraction-review.ts';
|
|
import {
|
|
DEFAULT_SOURCE_BOOSTS,
|
|
DEFAULT_HARD_EXCLUDES,
|
|
parseSourceBoostEnv,
|
|
parseHardExcludesEnv,
|
|
resolveBoostMap,
|
|
resolveHardExcludes,
|
|
} from '../src/core/search/source-boost.ts';
|
|
|
|
const { escapeLikePattern, escapeSqlLiteral, buildLikePrefixLiteral } = __test__;
|
|
|
|
describe('escapeLikePattern', () => {
|
|
test('escapes %', () => {
|
|
expect(escapeLikePattern('foo%bar')).toBe('foo\\%bar');
|
|
});
|
|
|
|
test('escapes _', () => {
|
|
expect(escapeLikePattern('foo_bar')).toBe('foo\\_bar');
|
|
});
|
|
|
|
test('escapes \\ (Postgres LIKE default escape char)', () => {
|
|
expect(escapeLikePattern('foo\\bar')).toBe('foo\\\\bar');
|
|
});
|
|
|
|
test('escapes all three together', () => {
|
|
expect(escapeLikePattern('a%b_c\\d')).toBe('a\\%b\\_c\\\\d');
|
|
});
|
|
|
|
test('leaves plain strings untouched', () => {
|
|
expect(escapeLikePattern('originals/talks/')).toBe('originals/talks/');
|
|
});
|
|
});
|
|
|
|
describe('escapeSqlLiteral', () => {
|
|
test('doubles single quotes', () => {
|
|
expect(escapeSqlLiteral("O'Brien")).toBe("O''Brien");
|
|
});
|
|
|
|
test('handles SQL injection attempts as literal data', () => {
|
|
// Classic injection pattern is rendered harmless because the doubled
|
|
// quote keeps it inside a string literal in the emitted SQL.
|
|
expect(escapeSqlLiteral("'; DROP TABLE pages; --")).toBe("''; DROP TABLE pages; --");
|
|
});
|
|
});
|
|
|
|
describe('buildLikePrefixLiteral', () => {
|
|
test('produces a quoted LIKE pattern with trailing %', () => {
|
|
expect(buildLikePrefixLiteral('originals/')).toBe("'originals/%'");
|
|
});
|
|
|
|
test('escapes meta-chars before adding the trailing %', () => {
|
|
// Input contains a literal % that should be escaped, and the trailing
|
|
// % we add is the LIKE wildcard.
|
|
expect(buildLikePrefixLiteral('weird%path/')).toBe("'weird\\%path/%'");
|
|
});
|
|
|
|
test('escapes single-quote in prefix as SQL literal', () => {
|
|
expect(buildLikePrefixLiteral("o'brien/")).toBe("'o''brien/%'");
|
|
});
|
|
});
|
|
|
|
describe('buildSourceFactorCase', () => {
|
|
test('returns plain 1.0 when detail is "high" (temporal bypass)', () => {
|
|
const result = buildSourceFactorCase('p.slug', { 'originals/': 1.5 }, 'high');
|
|
expect(result).toBe('1.0');
|
|
});
|
|
|
|
test('temporal bypass tolerates uppercase / whitespace from MCP boundary', () => {
|
|
// Agents passing JSON over MCP can send "HIGH" or "high " (trailing
|
|
// space). The bypass must catch these — otherwise loose-string callers
|
|
// silently get boosted ranking instead of temporal bypass.
|
|
const map = { 'originals/': 1.5 };
|
|
expect(buildSourceFactorCase('p.slug', map, 'HIGH' as 'high')).toBe('1.0');
|
|
expect(buildSourceFactorCase('p.slug', map, 'high ' as 'high')).toBe('1.0');
|
|
expect(buildSourceFactorCase('p.slug', map, ' High ' as 'high')).toBe('1.0');
|
|
});
|
|
|
|
test('returns plain 1.0 when boost map is empty', () => {
|
|
expect(buildSourceFactorCase('p.slug', {}, 'medium')).toBe('1.0');
|
|
});
|
|
|
|
test('emits a CASE expression for non-high detail (unverified guard first — issue #160)', () => {
|
|
const result = buildSourceFactorCase('p.slug', { 'originals/': 1.5 }, 'medium');
|
|
expect(result).toBe(
|
|
`(CASE WHEN ${unverifiedExtractionFragment('p')} THEN 1.0 WHEN p.slug LIKE 'originals/%' THEN 1.5 ELSE 1.0 END)`,
|
|
);
|
|
});
|
|
|
|
test('sorts prefixes by length descending so longest-match wins', () => {
|
|
const result = buildSourceFactorCase(
|
|
'p.slug',
|
|
{ 'media/': 0.9, 'media/articles/': 1.1, 'media/x/': 0.7 },
|
|
'medium',
|
|
);
|
|
// Longest first: media/articles/ (15), media/x/ (8), media/ (6)
|
|
const m = result.match(/LIKE '([^']+)%'/g);
|
|
expect(m).toEqual([
|
|
"LIKE 'media/articles/%'",
|
|
"LIKE 'media/x/%'",
|
|
"LIKE 'media/%'",
|
|
]);
|
|
});
|
|
|
|
test('detail=low and detail=undefined both emit the boost CASE', () => {
|
|
const map = { 'originals/': 1.5 };
|
|
expect(buildSourceFactorCase('p.slug', map, 'low')).toContain('CASE WHEN');
|
|
expect(buildSourceFactorCase('p.slug', map, undefined)).toContain('CASE WHEN');
|
|
});
|
|
|
|
test('rejects non-finite or negative factors', () => {
|
|
const result = buildSourceFactorCase(
|
|
'p.slug',
|
|
{ 'good/': 1.5, 'nan/': NaN, 'neg/': -1, 'inf/': Infinity },
|
|
'medium',
|
|
);
|
|
expect(result).toBe(
|
|
`(CASE WHEN ${unverifiedExtractionFragment('p')} THEN 1.0 WHEN p.slug LIKE 'good/%' THEN 1.5 ELSE 1.0 END)`,
|
|
);
|
|
});
|
|
|
|
test('uses the supplied slug column reference', () => {
|
|
expect(buildSourceFactorCase('slug', { 'originals/': 1.5 }, 'medium'))
|
|
.toContain('WHEN slug LIKE');
|
|
});
|
|
});
|
|
|
|
describe('buildHardExcludeClause', () => {
|
|
test('returns empty string when prefixes is empty', () => {
|
|
expect(buildHardExcludeClause('p.slug', [])).toBe('');
|
|
});
|
|
|
|
test('emits NOT (col LIKE ... OR col LIKE ...)', () => {
|
|
const result = buildHardExcludeClause('p.slug', ['test/', 'archive/']);
|
|
expect(result).toBe(`AND NOT (p.slug LIKE 'test/%' OR p.slug LIKE 'archive/%')`);
|
|
});
|
|
|
|
test('escapes %, _, and \\ as LIKE meta-characters', () => {
|
|
// CEO pass 4 + codex finding: backslash is Postgres LIKE's default escape char.
|
|
// A literal backslash in a user-supplied prefix must be escaped to \\ so
|
|
// it's treated as data, not as "escape the next char".
|
|
const result = buildHardExcludeClause('p.slug', ['weird\\path/']);
|
|
expect(result).toBe(`AND NOT (p.slug LIKE 'weird\\\\path/%')`);
|
|
});
|
|
|
|
test('treats SQL-injection-style input as literal', () => {
|
|
const result = buildHardExcludeClause('p.slug', ["'; DROP TABLE pages; --"]);
|
|
// Single quotes get doubled — the injection becomes inert text inside
|
|
// the string literal.
|
|
expect(result).toContain("''; DROP TABLE pages; --");
|
|
// Sanity: the structure of the clause is intact.
|
|
expect(result).toMatch(/^AND NOT \(p\.slug LIKE '.*%'\)$/);
|
|
});
|
|
|
|
test('skips empty-string prefixes', () => {
|
|
const result = buildHardExcludeClause('p.slug', ['test/', '', 'archive/']);
|
|
// Two LIKE clauses, one OR.
|
|
expect((result.match(/LIKE/g) || []).length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('parseSourceBoostEnv', () => {
|
|
test('parses comma-separated prefix:factor pairs', () => {
|
|
expect(parseSourceBoostEnv('originals/:1.8,openclaw/chat/:0.3'))
|
|
.toEqual({ 'originals/': 1.8, 'openclaw/chat/': 0.3 });
|
|
});
|
|
|
|
test('returns empty object for undefined or empty', () => {
|
|
expect(parseSourceBoostEnv(undefined)).toEqual({});
|
|
expect(parseSourceBoostEnv('')).toEqual({});
|
|
});
|
|
|
|
test('skips malformed entries', () => {
|
|
expect(parseSourceBoostEnv('bogus,no-colon,originals/:abc,valid/:1.5'))
|
|
.toEqual({ 'valid/': 1.5 });
|
|
});
|
|
|
|
test('rejects negative factors', () => {
|
|
expect(parseSourceBoostEnv('foo/:-1.0,bar/:0.5')).toEqual({ 'bar/': 0.5 });
|
|
});
|
|
|
|
test('accepts factor=0 (legal but performance-inferior to hard-exclude)', () => {
|
|
expect(parseSourceBoostEnv('foo/:0')).toEqual({ 'foo/': 0 });
|
|
});
|
|
|
|
test('uses last colon to separate prefix from factor', () => {
|
|
// Edge case: someone puts a colon inside the prefix. Last colon wins.
|
|
expect(parseSourceBoostEnv('foo:bar/:1.5')).toEqual({ 'foo:bar/': 1.5 });
|
|
});
|
|
});
|
|
|
|
describe('parseHardExcludesEnv', () => {
|
|
test('parses comma-separated prefixes', () => {
|
|
expect(parseHardExcludesEnv('test/,scratch/,private/'))
|
|
.toEqual(['test/', 'scratch/', 'private/']);
|
|
});
|
|
|
|
test('returns empty array for undefined', () => {
|
|
expect(parseHardExcludesEnv(undefined)).toEqual([]);
|
|
});
|
|
|
|
test('trims whitespace and drops empty entries', () => {
|
|
expect(parseHardExcludesEnv(' test/ , , scratch/ ')).toEqual(['test/', 'scratch/']);
|
|
});
|
|
});
|
|
|
|
describe('resolveBoostMap', () => {
|
|
test('returns defaults when env is unset', () => {
|
|
expect(resolveBoostMap(undefined)).toEqual(DEFAULT_SOURCE_BOOSTS);
|
|
});
|
|
|
|
test('env override takes precedence over defaults', () => {
|
|
const merged = resolveBoostMap('originals/:99');
|
|
expect(merged['originals/']).toBe(99);
|
|
// Other defaults still present.
|
|
expect(merged['concepts/']).toBe(DEFAULT_SOURCE_BOOSTS['concepts/']);
|
|
});
|
|
|
|
test('env-only entries are added on top of defaults', () => {
|
|
const merged = resolveBoostMap('newprefix/:2.5');
|
|
expect(merged['newprefix/']).toBe(2.5);
|
|
expect(merged['originals/']).toBe(DEFAULT_SOURCE_BOOSTS['originals/']);
|
|
});
|
|
});
|
|
|
|
describe('resolveHardExcludes', () => {
|
|
test('returns defaults when nothing is overridden', () => {
|
|
const r = resolveHardExcludes(undefined, undefined, undefined);
|
|
for (const p of DEFAULT_HARD_EXCLUDES) expect(r).toContain(p);
|
|
});
|
|
|
|
test('caller exclude_slug_prefixes adds to the union', () => {
|
|
const r = resolveHardExcludes(['scratch/'], undefined, undefined);
|
|
expect(r).toContain('scratch/');
|
|
expect(r).toContain('test/'); // default still present
|
|
});
|
|
|
|
test('include_slug_prefixes opts back in', () => {
|
|
const r = resolveHardExcludes(undefined, ['test/'], undefined);
|
|
expect(r).not.toContain('test/');
|
|
// Other defaults still present.
|
|
expect(r).toContain('attachments/');
|
|
});
|
|
|
|
test('env GBRAIN_SEARCH_EXCLUDE adds to the union', () => {
|
|
const r = resolveHardExcludes(undefined, undefined, 'envdir/');
|
|
expect(r).toContain('envdir/');
|
|
});
|
|
|
|
test('include subtracts from env-supplied excludes too', () => {
|
|
const r = resolveHardExcludes(undefined, ['envdir/'], 'envdir/');
|
|
expect(r).not.toContain('envdir/');
|
|
});
|
|
});
|
|
|
|
// issue #1777 — archive/ moved from hard-exclude to a 0.5 source-boost demote.
|
|
describe('archive demote (issue #1777)', () => {
|
|
test('archive/ is NOT a default hard-exclude (regression guard)', () => {
|
|
expect(DEFAULT_HARD_EXCLUDES).not.toContain('archive/');
|
|
// The genuine-noise prefixes stay excluded.
|
|
expect(DEFAULT_HARD_EXCLUDES).toContain('test/');
|
|
expect(DEFAULT_HARD_EXCLUDES).toContain('attachments/');
|
|
expect(DEFAULT_HARD_EXCLUDES).toContain('.raw/');
|
|
});
|
|
|
|
test('resolveHardExcludes() never includes archive/ by default', () => {
|
|
expect(resolveHardExcludes()).not.toContain('archive/');
|
|
});
|
|
|
|
test('archive/ is demoted to 0.5 in the boost map', () => {
|
|
expect(DEFAULT_SOURCE_BOOSTS['archive/']).toBe(0.5);
|
|
expect(resolveBoostMap()['archive/']).toBe(0.5);
|
|
});
|
|
|
|
test('buildSourceFactorCase emits an archive/ demote branch', () => {
|
|
const sql = buildSourceFactorCase('p.slug', resolveBoostMap(), undefined);
|
|
expect(sql).toContain("WHEN p.slug LIKE 'archive/%' THEN 0.5");
|
|
});
|
|
|
|
test('detail=high bypasses the source factor (archive ranks normally)', () => {
|
|
expect(buildSourceFactorCase('p.slug', resolveBoostMap(), 'high')).toBe('1.0');
|
|
});
|
|
|
|
test('escapeLikePattern is exported at top level (CV-3a contract)', () => {
|
|
expect(typeof topLevelEscapeLikePattern).toBe('function');
|
|
expect(topLevelEscapeLikePattern('a_b%c\\d')).toBe('a\\_b\\%c\\\\d');
|
|
});
|
|
});
|
|
|
|
// v0.26.5 — visibility clause for soft-deleted pages and archived sources.
|
|
describe('buildVisibilityClause (v0.26.5)', () => {
|
|
test('emits both predicates joined by AND with a leading AND', () => {
|
|
const clause = buildVisibilityClause('p', 's');
|
|
// Leading AND so callers can splice unconditionally.
|
|
expect(clause.startsWith('AND ')).toBe(true);
|
|
// Both predicates present: page-level deleted_at IS NULL + source-level NOT archived.
|
|
expect(clause).toContain('p.deleted_at IS NULL');
|
|
expect(clause).toContain('NOT s.archived');
|
|
// v0.42 (#1699): also excludes quarantined pages (flagged pages stay visible).
|
|
expect(clause).toContain("? 'quarantine'");
|
|
});
|
|
|
|
test('uses the supplied aliases verbatim', () => {
|
|
expect(buildVisibilityClause('pp', 'src')).toBe(
|
|
"AND pp.deleted_at IS NULL AND NOT src.archived AND NOT (COALESCE(pp.frontmatter, '{}'::jsonb) ? 'quarantine')",
|
|
);
|
|
});
|
|
|
|
test('drift guard: quarantine fragment comes from quarantine.ts single source of truth', async () => {
|
|
// buildVisibilityClause MUST consume quarantineFilterFragment so the search
|
|
// filter and the marker key can't drift (#1699 maintainability finding).
|
|
const { quarantineFilterFragment } = await import('../src/core/quarantine.ts');
|
|
expect(buildVisibilityClause('p', 's')).toContain(quarantineFilterFragment('p'));
|
|
expect(buildVisibilityClause('xx', 's')).toContain(quarantineFilterFragment('xx'));
|
|
});
|
|
|
|
test('does NOT bypass on detail level — visibility is a contract, not a temporal preference', () => {
|
|
// Distinct from buildSourceFactorCase: there's no detail-gated short-circuit.
|
|
// Soft-deleted content stays hidden regardless of caller's detail level.
|
|
// Function signature has no detail param at all; this test pins that contract.
|
|
expect(buildVisibilityClause.length).toBe(2);
|
|
});
|
|
|
|
test('emits a stable string regardless of call order (idempotent for snapshot tests)', () => {
|
|
const a = buildVisibilityClause('p', 's');
|
|
const b = buildVisibilityClause('p', 's');
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
test('produces no JSONB containment in the output (column-based, not @>)', () => {
|
|
// Issue 5 contract: archived was promoted from JSONB key to real column.
|
|
// The visibility clause must not regress to JSONB containment.
|
|
const clause = buildVisibilityClause('p', 's');
|
|
expect(clause).not.toContain('@>');
|
|
expect(clause).not.toContain('config');
|
|
});
|
|
});
|