mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
* feat: migration v68 — eval_candidates.embedding_column Schema migration ALTERs eval_candidates to add a nullable embedding_column TEXT column. Per-row capture metadata so `gbrain eval replay` reproduces the same column the capture ran against (D16 / CDX-10). NULL-tolerant: pre-v0.36 rows fall back to current default. Renumbered v67→v68 because master claimed v67 for facts_typed_claim_columns during this branch's lifetime. PGLite parity via sqlFor.pglite — same ALTER IF NOT EXISTS. * feat: dynamic embedding column — core (resolver, types, gateway, engines) The read-path foundation for routing search through any populated embedding column, not just OpenAI 1536. src/core/search/embedding-column.ts (new) is the canonical seam. Single source of truth for column → provider/dim/type lookup. Validates registry keys via regex (/^[a-z_][a-z0-9_]*$/), uses Object.create(null) + Object.hasOwn so 'constructor' and other inherited names can't masquerade as registered columns. Identifier-quoting on SQL interpolation as defense in depth. src/core/types.ts widens SearchOpts.embeddingColumn to accept ResolvedColumn descriptors at the engine boundary; adds EmbeddingColumnConfig + ResolvedColumn exports. src/core/config.ts merges embedding_columns + search_embedding_column from the DB plane via loadConfigWithEngine, mirroring the existing embedding_multimodal_model pattern. Handles the no-file case so env-only Postgres installs see DB-plane overrides (codex /ship #3). src/core/ai/gateway.ts: embedQuery(text, opts) + embed(texts, opts) accept embeddingModel + dimensions overrides. isAvailable(touchpoint, modelOverride?) so hybrid asks 'is the active column's provider reachable?' not 'is the global default reachable?' (CDX-4 / D10). Engines: searchVector accepts ResolvedColumn descriptors via normalizeEngineColumn; engine code is config-free and unit-testable. getEmbeddingsByChunkIds(ids, column?) so cosineReScore hydrates from the active column instead of always 'embedding' (CDX-3 / D9). Identifier-quoting belt at the SQL boundary. src/core/eval-capture.ts threads embedding_column from hybridSearch meta into the persisted capture row. * feat: dynamic embedding column — integration (hybrid, ops, doctor) Wires the resolver into hybridSearch, the query op, doctor, and the config command. src/core/search/hybrid.ts: resolves the column once at the boundary, threads the descriptor into engine calls, routes embedQuery through the resolved column's provider/dims, and calls isCacheSafe (not isDefaultColumn) for cache skip so user overrides of the 'embedding' builtin can't leak across vector spaces (CDX-4). cosineReScore now hydrates from the active column. src/core/search/mode.ts: KNOBS_HASH_VERSION 2→3, append-only new fields col= and prov= alongside floor_ratio. Cache rows from different columns or providers now sit in different keyspaces — cross-column contamination impossible. src/core/operations.ts: query op accepts embedding_column param for per-call A/B benchmarking. search op (keyword-only) deliberately does NOT (CDX-9 / D15) — would be silent UX. src/commands/doctor.ts: new embedding_column_registry check. Batch format_type probe (D13) catches dim drift that information_schema.columns.udt_name can't. Batch pg_indexes probe (D5) warns on missing HNSW. Coverage % on active column, gates at <90% (D14), short-circuits on empty brains (codex /ship #5). src/commands/config.ts: validates embedding_columns JSON shape at set time, runs the coverage gate when setting search_embedding_column, uses Object.hasOwn for the registry lookup. src/commands/eval-replay.ts: replay re-runs queries against the captured embedding_column so post-flip-config replays don't surface as false-positive regressions. * test: dynamic embedding column — unit + e2e coverage 50 unit cases for the resolver (resolution chain, registry merge, validation, prototype pollution, descriptor passthrough, isCacheSafe, normalizeEngineColumn). 8 gateway override cases — embeddingModel + dimensions flow into providerOptions, isAvailable(touchpoint, override) routes to the right recipe, unknown models throw clean. 4 cosineReScore + 6 ops + 5 knobs-hash + 7 mode + 9 PGLite E2E + 7 Postgres E2E + 5 eval-replay column metadata. Postgres E2E (gated on DATABASE_URL) covers halfvec(2560) end-to-end on real pgvector, EXPLAIN-visible HNSW index on the alternate column, format_type-based dim drift catch, and the <90% coverage gate. Pins every codex /ship fix: prototype-pollution rejection ('constructor' as column name), descriptor passthrough validation (rejects SQL-shaped strings in dimensions), isCacheSafe semantics (space-based, not name-based). Total: 141 new + extended cases, all green. * chore: bump version and changelog (v0.36.3.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync to v0.36.3.0 Add CLAUDE.md key-files entry for src/core/search/embedding-column.ts. Annotate hybrid.ts, gateway.ts, doctor.ts, and migrate.ts entries with v0.36.3.0 wave changes (ResolvedColumn threading, embedQuery model override, embedding_column_registry check, migration v68). Document knobs_hash v=2 → v=3 bump under the Search Mode section. Regenerate llms-full.txt from the updated CLAUDE.md so the auto-checked bundle matches source (build-llms.test.ts CI guard). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): two CI failures from v0.36.3.0 1. test/loadConfig-merge.test.ts: update the 'returns null when base config is null' contract test. Pre-v0.36 the function returned null for null base; the codex /ship #3 fix changed that to synthesize a minimal `{ engine: 'postgres' }` so env-only installs see DB-plane overrides. Test now pins the new contract + adds a round-trip case asserting the merge actually surfaces `embedding_columns` / `search_embedding_column` set via gbrain config set on a null base. 2. test/schema-bootstrap-coverage.test.ts was failing because eval_candidates.embedding_column (added by migration v68) wasn't covered by applyForwardReferenceBootstrap. Fix: add the column to PGLITE_SCHEMA_SQL's eval_candidates CREATE TABLE definition (and src/schema.sql for parity) so fresh installs get it natively. The coverage test's third tier (schemaCreateTableCols) now finds it. Regenerated schema-embedded.ts via bun run build:schema. Schema-blob path is cleaner than COLUMN_EXEMPTIONS — fresh installs skip the migration entirely; upgrade installs still run v68. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
/**
|
|
* Op-layer capture wrapper (v0.21.0).
|
|
*
|
|
* Catches MCP + CLI + subagent tool-bridge traffic from a single site —
|
|
* the `query` and `search` op handlers in src/core/operations.ts decorate
|
|
* themselves with `captureEvalCandidate` before they run. Post-codex O1+O2
|
|
* design: the MCP server used to be the capture site, but subagent tool
|
|
* calls dispatch straight to op.handler via brain-allowlist.ts and would
|
|
* have been invisible. Moving the hook to the op layer covers every caller.
|
|
*
|
|
* Best-effort, not await'd by the caller. Every failure is routed through
|
|
* `engine.logEvalCaptureFailure(reason)` so `gbrain doctor` can see drops
|
|
* cross-process (in-process counters would be invisible from doctor's
|
|
* separate process).
|
|
*
|
|
* Data-flow (happy path):
|
|
*
|
|
* op.handler() ──▶ {results, meta}
|
|
* │
|
|
* (fire-and-forget from caller)
|
|
* ▼
|
|
* outer try / inner .catch
|
|
* │
|
|
* ▼
|
|
* scrubPii(query)
|
|
* │
|
|
* ▼
|
|
* buildEvalCandidateInput(...)
|
|
* │
|
|
* ▼
|
|
* engine.logEvalCandidate(input)
|
|
*
|
|
* Every error path — scrub throw, build throw, INSERT reject — lands at
|
|
* `logEvalCaptureFailure(reason)` with the right reason tag.
|
|
*/
|
|
|
|
import type { BrainEngine } from './engine.ts';
|
|
import type {
|
|
EvalCandidateInput,
|
|
EvalCaptureFailureReason,
|
|
HybridSearchMeta,
|
|
SearchResult,
|
|
} from './types.ts';
|
|
import type { GBrainConfig } from './config.ts';
|
|
import { scrubPii } from './eval-capture-scrub.ts';
|
|
|
|
// HybridSearchMeta is canonical in src/core/types.ts and exported via the
|
|
// public `gbrain/types` subpath. Surfaced from hybridSearch via the
|
|
// optional onMeta callback in HybridSearchOpts (Lane 1C).
|
|
export type { HybridSearchMeta };
|
|
|
|
/** Context needed to build a capture row. Bundled so op handlers don't thread 8 args. */
|
|
export interface CaptureContext {
|
|
/** 'query' or 'search'; captured only for these two ops. */
|
|
tool_name: 'query' | 'search';
|
|
/** Pre-scrub query text. scrubPii runs INSIDE buildEvalCandidateInput when scrub_pii !== false. */
|
|
query: string;
|
|
/** Result set from the op handler. */
|
|
results: SearchResult[];
|
|
/** Side-channel metadata from hybridSearch. For 'search' ops, pass vector_enabled:false, others null/false. */
|
|
meta: HybridSearchMeta;
|
|
/** How long the underlying op took, in milliseconds. */
|
|
latency_ms: number;
|
|
/** OperationContext.remote — true for MCP callers, false for local CLI. */
|
|
remote: boolean;
|
|
/** The `expand` flag as requested by the caller (query-only; null for search). */
|
|
expand_enabled: boolean | null;
|
|
/** The `detail` flag as requested (query-only). */
|
|
detail: 'low' | 'medium' | 'high' | null;
|
|
/** OperationContext.jobId if present. */
|
|
job_id: number | null;
|
|
/** OperationContext.subagentId if present. */
|
|
subagent_id: number | null;
|
|
}
|
|
|
|
/**
|
|
* Build the insert row for `eval_candidates` from a capture context.
|
|
*
|
|
* Runs the PII scrubber on `query` unless `scrub_pii === false`. Pure
|
|
* function: throws only if the scrubber throws (caller wraps in try/catch).
|
|
*
|
|
* Exported for testing and for CLI backfill paths; the hot-path caller is
|
|
* `captureEvalCandidate` below.
|
|
*/
|
|
export function buildEvalCandidateInput(
|
|
ctx: CaptureContext,
|
|
opts: { scrub_pii?: boolean } = {},
|
|
): EvalCandidateInput {
|
|
const shouldScrub = opts.scrub_pii !== false;
|
|
const query = shouldScrub ? scrubPii(ctx.query) : ctx.query;
|
|
|
|
// Deduplicate + preserve order for slug + source_id extraction.
|
|
// Both arrays are small (hybridSearch clamps at 100 results) so the
|
|
// O(n) Set-based dedup is fine.
|
|
const slugsSet = new Set<string>();
|
|
const sourceSet = new Set<string>();
|
|
const chunkIds: number[] = [];
|
|
for (const r of ctx.results) {
|
|
slugsSet.add(r.slug);
|
|
if (r.source_id) sourceSet.add(r.source_id);
|
|
chunkIds.push(r.chunk_id);
|
|
}
|
|
|
|
return {
|
|
tool_name: ctx.tool_name,
|
|
query,
|
|
retrieved_slugs: [...slugsSet],
|
|
retrieved_chunk_ids: chunkIds,
|
|
source_ids: [...sourceSet],
|
|
expand_enabled: ctx.expand_enabled,
|
|
detail: ctx.detail,
|
|
detail_resolved: ctx.meta.detail_resolved,
|
|
vector_enabled: ctx.meta.vector_enabled,
|
|
expansion_applied: ctx.meta.expansion_applied,
|
|
latency_ms: ctx.latency_ms,
|
|
remote: ctx.remote,
|
|
job_id: ctx.job_id,
|
|
subagent_id: ctx.subagent_id,
|
|
// v0.36 (D16 / CDX-10): persist the column that ran. Null for
|
|
// keyword-only `search` (meta.embedding_column omitted), preserving
|
|
// back-compat with rows captured before the column tracking landed.
|
|
embedding_column: ctx.meta.embedding_column ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map a caught error to an `EvalCaptureFailureReason` so doctor can
|
|
* group failures by cause (DB down vs RLS reject vs CHECK violation).
|
|
*
|
|
* Narrow by Postgres SQLSTATE where we can (postgres-js + PGLite both
|
|
* surface `.code`), fall back to 'other'.
|
|
*/
|
|
export function classifyCaptureFailure(err: unknown): EvalCaptureFailureReason {
|
|
if (err && typeof err === 'object') {
|
|
const code = (err as { code?: string }).code;
|
|
if (code === '23514') return 'check_violation'; // CHECK constraint violation
|
|
if (code === '42501') return 'rls_reject'; // insufficient_privilege
|
|
if (code === '42P01') return 'db_down'; // undefined_table (pre-v25)
|
|
if (code === '53300' || code === '08006' || code === '08003') return 'db_down';
|
|
const name = (err as { name?: string }).name;
|
|
if (name === 'RegExpMatchError' || name === 'SyntaxError') {
|
|
return 'scrubber_exception';
|
|
}
|
|
}
|
|
return 'other';
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget capture side-effect. Never throws: every failure is
|
|
* swallowed, classified, and routed through `engine.logEvalCaptureFailure`
|
|
* (itself wrapped — failure-of-failure is logged to stderr and dropped).
|
|
*
|
|
* Callers invoke as `void captureEvalCandidate(...)` — we explicitly do
|
|
* NOT await so the op response latency is unaffected.
|
|
*/
|
|
export async function captureEvalCandidate(
|
|
engine: BrainEngine,
|
|
ctx: CaptureContext,
|
|
opts: { scrub_pii?: boolean } = {},
|
|
): Promise<void> {
|
|
try {
|
|
const input = buildEvalCandidateInput(ctx, opts);
|
|
await engine.logEvalCandidate(input);
|
|
} catch (err) {
|
|
const reason = classifyCaptureFailure(err);
|
|
try {
|
|
await engine.logEvalCaptureFailure(reason);
|
|
} catch (failureErr) {
|
|
// Failure-of-failure: last-resort stderr. Doctor can't see this
|
|
// row, but we've exhausted the persistent path.
|
|
// eslint-disable-next-line no-console
|
|
console.warn('[eval-capture] secondary failure logging also failed:', failureErr);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check whether capture is enabled for this process.
|
|
*
|
|
* Resolution order:
|
|
* 1. `config.eval.capture === true` → on (explicit user opt-in wins)
|
|
* 2. `config.eval.capture === false` → off (explicit user opt-out wins)
|
|
* 3. `process.env.GBRAIN_CONTRIBUTOR_MODE === '1'` → on (contributor opt-in)
|
|
* 4. otherwise → off (default-off, privacy-positive for end users)
|
|
*
|
|
* The default flipped in v0.25.0 from "on for everyone" to "off unless you
|
|
* opt in." Capturing every query a real user runs without their consent is
|
|
* a footgun even with PII scrubbing; tying capture to CONTRIBUTOR_MODE makes
|
|
* the developer-skill nature of the feature explicit. Production users get
|
|
* a quiet brain; contributors get the BrainBench-Real replay loop with one
|
|
* shell rc line. See docs/eval-bench.md and CONTRIBUTING.md.
|
|
*
|
|
* Takes the already-loaded config so callers control the loadConfig()
|
|
* lifecycle (MCP server loads once at boot, CLI commands load per-invocation).
|
|
*/
|
|
export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): boolean {
|
|
if (config?.eval?.capture === true) return true;
|
|
if (config?.eval?.capture === false) return false;
|
|
return process.env.GBRAIN_CONTRIBUTOR_MODE === '1';
|
|
}
|
|
|
|
/**
|
|
* PII scrubbing enabled? Defaults to true; explicit `false` opts out.
|
|
*
|
|
* Independent of `isEvalCaptureEnabled` — scrubbing only matters when capture
|
|
* is actually running, but the gate stays separate so CONTRIBUTOR_MODE
|
|
* doesn't accidentally turn off scrubbing on a brain that happens to also
|
|
* have explicit `capture: true`.
|
|
*/
|
|
export function isEvalScrubEnabled(config: GBrainConfig | null | undefined): boolean {
|
|
return config?.eval?.scrub_pii !== false;
|
|
}
|