Files
gbrain/src/core/quarantine.ts
0bfe0d0c7e v0.42.8.0 feat: content-quality gate on sync — quarantine junk + flag boilerplate (#1699) (#1756)
* feat: content-quality gate on sync — quarantine junk + flag boilerplate (#1699)

Three-tier disposition at the importFromContent narrow waist:
- High-confidence junk (Cloudflare/CAPTCHA interstitial patterns + operator
  literals) -> quarantine (hidden from search, zero chunks) or reject.
- Fuzzy markup-heavy (prose-vs-markup ratio, warn-tier window, code-exempt)
  -> content_flag marker, stays searchable, agent warned.
- Oversize -> existing embed_skip soft-block + content_flag:oversized warning.

Agent-warning channel: SearchResult.content_flag (stamped in hybridSearch +
the keyword-only search op) and a top-level content_flag on get_page.
New quarantine.ts markers, gbrain quarantine CLI (list/clear/scan), doctor
quarantined_pages + flagged_pages checks (engine.executeRaw, works on PGLite),
sources-audit disposition awareness, markup-heavy lint rule, config keys.

Security: gate-owned markers stripped from untrusted (remote MCP) frontmatter
so a write-scoped client can't hide pages or inject the warning channel.
Markers excluded from content_hash so flagged pages don't re-embed every sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.42.8.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document content-quality gate (quarantine + content_flag) for v0.42.8.0

Add CLAUDE.md Key Files + Commands entries for the #1699 content-quality
gate: src/core/quarantine.ts, gbrain quarantine CLI (list/clear/scan),
the agent-warning channel (SearchResult.content_flag + get_page), doctor
quarantined_pages/flagged_pages checks, the markup-heavy lint rule,
sources-audit disposition awareness, and the three new content_sanity
config keys. Regenerate llms-full.txt from CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:03:09 -07:00

154 lines
6.7 KiB
TypeScript

/**
* Quarantine + content-flag markers: the two frontmatter markers the
* content-quality gate writes (issue #1699).
*
* Why two markers, not one (Q1=A confidence split):
* - `quarantine` HIDES. Set ONLY for high-confidence junk (Cloudflare /
* CAPTCHA interstitial patterns + operator literals). The page lands
* (reviewable via get_page / quarantine list) but writes zero chunks
* and is excluded from search via `QUARANTINE_FILTER_FRAGMENT`.
* - `content_flag` WARNS, does NOT hide. Set for the fuzzy markup-ratio
* signal and for oversize. The page stays fully searchable; the marker
* rides along so search results / get_page can tell the agent "this
* looks like boilerplate / is unusually large — examine it." There is
* NO SQL filter fragment for content_flag, by design.
*
* Three distinct markers, three reasons (Codex #6 — never overload one to
* mean another):
* - `embed_skip` (src/core/embed-skip.ts) = "oversized-but-clean, not embedded"
* - `quarantine` (here) = "junk, hidden from search"
* - `content_flag` (here) = "odd, examine, still here"
* A page can carry more than one (oversize → embed_skip + content_flag:oversized);
* each is cleared independently.
*
* Sibling of `src/core/embed-skip.ts` — same marker-as-JSONB-object pattern,
* same JSONB `?` existence check that works identically on Postgres (real)
* and PGLite (PostgreSQL 17.5 in WASM). No schema migration (D4): both are
* frontmatter JSONB keys.
*
* v0.42 follow-up: promote `quarantine` to a schema column +
* partial index if the quarantined subset grows large. Single change
* site (this module).
*/
// ---------------------------------------------------------------------------
// quarantine marker (HIDES)
// ---------------------------------------------------------------------------
/** Frontmatter key for the HIDE marker. Stable contract. */
export const QUARANTINE_KEY = 'quarantine';
/** SQL fragment that excludes quarantined pages from search, parameterized on
* the page-table alias. Single source of truth — `buildVisibilityClause`
* (`src/core/search/sql-ranking.ts`) calls this so the search filter and the
* marker key can never drift. Mirrors `EMBED_SKIP_FILTER_FRAGMENT` shape:
* JSONB `?` existence, negated so we KEEP rows WITHOUT the marker.
* `pageAlias` is engine-supplied (never user input), so no escaping needed. */
export function quarantineFilterFragment(pageAlias: string): string {
return `NOT (COALESCE(${pageAlias}.frontmatter, '{}'::jsonb) ? '${QUARANTINE_KEY}')`;
}
/** The `p`-aliased instance — the common case (all 6 search call sites alias
* pages as `p`). Kept as a constant for parity with `EMBED_SKIP_FILTER_FRAGMENT`
* and for any future stale/orphan-chunk query that needs it. */
export const QUARANTINE_FILTER_FRAGMENT = quarantineFilterFragment('p');
export interface QuarantineMarker {
/** Why the page was quarantined. The high-confidence junk reasons. */
reason: 'junk_pattern' | 'literal_substring';
/** Human-readable detail (which pattern/literal names fired). */
detail: string;
/** ISO 8601 timestamp at assessment time. */
assessed_at: string;
/** Body bytes at assessment, for operator visibility. */
bytes?: number;
}
/** Build the canonical quarantine marker. Spread onto frontmatter before
* write: `frontmatter[QUARANTINE_KEY] = buildQuarantineMarker(...)`. */
export function buildQuarantineMarker(
reason: QuarantineMarker['reason'],
detail: string,
extra: { bytes?: number; now?: Date } = {},
): QuarantineMarker {
return {
reason,
detail,
assessed_at: (extra.now ?? new Date()).toISOString(),
...(extra.bytes !== undefined ? { bytes: extra.bytes } : {}),
};
}
/** JS-side predicate. True when the frontmatter has the quarantine key set
* to any non-null value. Accepts null/undefined frontmatter. Key-existence
* is the trigger; marker contents are diagnostic (mirrors the SQL fragment). */
export function isQuarantined(frontmatter: Record<string, unknown> | null | undefined): boolean {
if (!frontmatter) return false;
const value = frontmatter[QUARANTINE_KEY];
return value !== undefined && value !== null;
}
/** JS-side filter: returns a new array with quarantined pages excluded. */
export function filterOutQuarantined<T extends { frontmatter?: Record<string, unknown> | null }>(
pages: ReadonlyArray<T>,
): T[] {
return pages.filter((p) => !isQuarantined(p.frontmatter ?? null));
}
// ---------------------------------------------------------------------------
// content_flag marker (WARNS, does NOT hide)
// ---------------------------------------------------------------------------
/** Frontmatter key for the WARN marker. Stable contract. */
export const CONTENT_FLAG_KEY = 'content_flag';
export interface ContentFlagMarker {
/** Which fuzzy/oversize tier fired. */
reason: 'markup_heavy' | 'oversized';
/** Human-readable detail surfaced to the agent on retrieval. */
detail: string;
/** ISO 8601 timestamp at assessment time. */
assessed_at: string;
/** Markup ratio (when reason is markup_heavy). */
markup_ratio?: number;
/** Body bytes (when reason is oversized). */
bytes?: number;
}
/** Build the canonical content-flag marker. NOTE: there is deliberately NO
* SQL filter fragment for content_flag — flagged pages stay searchable; the
* marker is READ INTO search/get_page output, never used to exclude. */
export function buildContentFlagMarker(
reason: ContentFlagMarker['reason'],
detail: string,
extra: { markup_ratio?: number; bytes?: number; now?: Date } = {},
): ContentFlagMarker {
return {
reason,
detail,
assessed_at: (extra.now ?? new Date()).toISOString(),
...(extra.markup_ratio !== undefined ? { markup_ratio: extra.markup_ratio } : {}),
...(extra.bytes !== undefined ? { bytes: extra.bytes } : {}),
};
}
/** Read the content-flag marker from frontmatter, or null. The shape is
* validated loosely — a `reason` string is the minimum contract. Used by
* the search projection + get_page to populate the agent-warning channel. */
export function getContentFlag(
frontmatter: Record<string, unknown> | null | undefined,
): { reason: string; detail: string } | null {
if (!frontmatter) return null;
const value = frontmatter[CONTENT_FLAG_KEY];
if (!value || typeof value !== 'object') return null;
const obj = value as Record<string, unknown>;
const reason = typeof obj.reason === 'string' ? obj.reason : null;
if (!reason) return null;
return { reason, detail: typeof obj.detail === 'string' ? obj.detail : '' };
}
/** True when the frontmatter carries a content-flag marker. */
export function hasContentFlag(frontmatter: Record<string, unknown> | null | undefined): boolean {
return getContentFlag(frontmatter) !== null;
}