mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 02:12:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88287775e5 |
+1
-12
@@ -54,7 +54,7 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'facts', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
|
||||
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
|
||||
// CLI-only commands whose handlers print their own --help text. These are
|
||||
// excluded from the generic short-circuit so detailed per-command and
|
||||
// per-subcommand usage stays reachable.
|
||||
@@ -991,8 +991,6 @@ const THIN_CLIENT_REFUSED_COMMANDS = new Set([
|
||||
// hint pointing at the routable MCP tools; per-subcommand splits are
|
||||
// a v0.31.x follow-up TODO.
|
||||
'takes', 'sources',
|
||||
// #1867: fence-backfill edits local .md fences + stamps the local DB.
|
||||
'facts',
|
||||
// v0.32 thin-client routing audit (Codex round 2 findings #2, #4):
|
||||
// - `pages` purge-deleted is admin+localOnly (operations.ts:856-864)
|
||||
// - `files` list / file_url MCP ops are localOnly (operations.ts:1769-1879)
|
||||
@@ -1028,7 +1026,6 @@ const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {
|
||||
migrate: "migrate runs on the host's local engine. Run on the host machine.",
|
||||
'apply-migrations': 'schema migrations run on the host. SSH and run there.',
|
||||
'repair-jsonb': 'repair-jsonb operates on the local DB only.',
|
||||
facts: 'facts fence-backfill edits local entity-page fences. Run on the host machine.',
|
||||
integrity: 'integrity scans local files. Run on the host machine.',
|
||||
serve: 'serve starts a server. Run on the host, not the thin client.',
|
||||
dream: 'dream runs the autopilot cycle on the host. `gbrain remote ping` queues one. (Native `gbrain dream` thin-client routing planned for v0.31.2.)',
|
||||
@@ -1858,13 +1855,6 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runEdgesBackfill(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'facts': {
|
||||
// #1867 — re-runnable fence-backfill for row_num-NULL legacy fact
|
||||
// rows (idempotent v0_32_2 phase B, exposed as an operator command).
|
||||
const { runFactsCommand } = await import('./commands/facts.ts');
|
||||
await runFactsCommand(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'whoknows': {
|
||||
// v0.33 (Issue #?): expertise + relationship-proximity routing.
|
||||
// MCP op `find_experts` (read-scoped) backs the same code path; CLI
|
||||
@@ -2345,7 +2335,6 @@ TOOLS
|
||||
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
|
||||
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
|
||||
orphans [--json] [--count] Find pages with no inbound wikilinks
|
||||
facts fence-backfill [--dry-run] Fence legacy fact rows (row_num NULL) onto entity pages
|
||||
salience [--days N] [--kind P] v0.29: pages ranked by emotional + activity salience
|
||||
anomalies [--since D] [--sigma N] v0.29: cohort-based statistical anomalies (tag, type)
|
||||
transcripts recent [--days N] v0.29: recent raw .txt transcripts (local-only)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* gbrain facts — fact-store maintenance surface (#1867).
|
||||
*
|
||||
* `fence-backfill` re-runs the v0_32_2 fence-backfill phase on demand.
|
||||
* Remote `extract_facts` deposits that predate the fence-write backstop
|
||||
* (and any legacy DB-only insert) leave `row_num IS NULL` rows that the
|
||||
* cycle extract_facts guard refuses to reconcile past — previously the
|
||||
* only remedy was the one-shot v0_32_2 migration, which the ledger marks
|
||||
* complete and never re-runs. The phase is idempotent (only touches
|
||||
* `row_num IS NULL` rows), so exposing it as a command is safe to re-run
|
||||
* any time the backlog reappears.
|
||||
*/
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
|
||||
import { phaseBFenceFacts } from './migrations/v0_32_2.ts';
|
||||
|
||||
function printHelp(): void {
|
||||
process.stderr.write(
|
||||
`Usage: gbrain facts fence-backfill [--dry-run]\n\n` +
|
||||
`Fence-backfill: appends every legacy fact row (row_num IS NULL) to its\n` +
|
||||
`entity page's \`## Facts\` fence and stamps row_num + source_markdown_slug\n` +
|
||||
`back onto the DB row. Idempotent — re-runs only pick up rows still\n` +
|
||||
`missing a fence assignment. Clears the backlog that makes the cycle's\n` +
|
||||
`extract_facts phase skip fence→DB reconciliation.\n\n` +
|
||||
` --dry-run report what would be fenced; no FS or DB writes\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runFactsCommand(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (!sub || sub === '--help' || sub === '-h') {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
if (sub !== 'fence-backfill') {
|
||||
process.stderr.write(`Unknown facts subcommand: ${sub}\n`);
|
||||
printHelp();
|
||||
setCliExitVerdict(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const result = await phaseBFenceFacts(engine, { dryRun });
|
||||
process.stderr.write(
|
||||
`fence-backfill: ${result.status}${result.detail ? ` — ${result.detail}` : ''}\n`,
|
||||
);
|
||||
if (result.status === 'failed') setCliExitVerdict(1);
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import type { BrainEngine } from '../../core/engine.ts';
|
||||
import { loadConfig, toEngineConfig } from '../../core/config.ts';
|
||||
import { createEngine } from '../../core/engine-factory.ts';
|
||||
import { upsertFactRow, parseFactsFence } from '../../core/facts-fence.ts';
|
||||
import { resolvePageFilePath } from '../../core/markdown.ts';
|
||||
|
||||
let testEngineOverride: BrainEngine | null = null;
|
||||
export function __setTestEngineOverride(engine: BrainEngine | null): void {
|
||||
@@ -149,16 +148,9 @@ function isLocalPathDirty(localPath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exported (not just via `__testing`) because `gbrain facts fence-backfill`
|
||||
* (#1867) re-runs this phase on demand: remote `extract_facts` deposits that
|
||||
* predate the fence-write backstop leave row_num-NULL rows the cycle guard
|
||||
* refuses to reconcile past. The phase is idempotent (only touches
|
||||
* `row_num IS NULL` rows), so re-running is always safe.
|
||||
*/
|
||||
export async function phaseBFenceFacts(
|
||||
async function phaseBFenceFacts(
|
||||
engine: BrainEngine | null,
|
||||
opts: Pick<OrchestratorOpts, 'dryRun'>,
|
||||
opts: OrchestratorOpts,
|
||||
): Promise<OrchestratorPhaseResult> {
|
||||
if (opts.dryRun) {
|
||||
// Dry-run: report what WOULD happen without touching FS or DB.
|
||||
@@ -246,11 +238,7 @@ export async function phaseBFenceFacts(
|
||||
for (const [key, group] of groups) {
|
||||
const [sourceId, entitySlug] = key.split('\0');
|
||||
const localPath = localPathById.get(sourceId)!;
|
||||
// resolvePageFilePath, NOT a bare join — non-default sources fence
|
||||
// into `<local_path>/.sources/<id>/<slug>.md`, the same path the
|
||||
// fence-write backstop and put_page write-through compute. A bare
|
||||
// join here diverges fence and DB for non-default sources (#2044).
|
||||
const filePath = resolvePageFilePath(localPath, entitySlug, sourceId);
|
||||
const filePath = join(localPath, `${entitySlug}.md`);
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
|
||||
try {
|
||||
@@ -281,21 +269,6 @@ export async function phaseBFenceFacts(
|
||||
const existingFence = parseFactsFence(body);
|
||||
const existingKeySet = new Set(existingFence.facts.map(f => `${f.claim}\0${f.source ?? ''}`));
|
||||
|
||||
// Seed appended row_nums from MAX(fence max, DB max) — same #2044
|
||||
// divergence guard as writeFactsToFence. When the fence at the
|
||||
// resolved path is missing/behind but the DB already holds stamped
|
||||
// rows for this slug (legacy wrong-path fence writes), fence-max+1
|
||||
// collides with idx_facts_fence_key on the post-rename UPDATE,
|
||||
// failing the page and leaving fence and DB disagreeing.
|
||||
const dbMaxRows = await engine.executeRaw<{ max: number | string | null }>(
|
||||
`SELECT MAX(row_num) AS max FROM facts
|
||||
WHERE source_id = $1 AND source_markdown_slug = $2`,
|
||||
[sourceId, entitySlug],
|
||||
);
|
||||
const dbMaxRowNum = Number(dbMaxRows[0]?.max ?? 0) || 0;
|
||||
const fenceMaxRowNum = existingFence.facts.reduce((m, f) => Math.max(m, f.rowNum), 0);
|
||||
let nextRowNum = Math.max(fenceMaxRowNum, dbMaxRowNum) + 1;
|
||||
|
||||
const assignments: Array<{ id: string; row_num: number }> = [];
|
||||
for (const row of group) {
|
||||
const key = `${row.fact}\0${row.source ?? ''}`;
|
||||
@@ -318,7 +291,6 @@ export async function phaseBFenceFacts(
|
||||
.toISOString().slice(0, 10)
|
||||
: undefined;
|
||||
const { body: updated, rowNum } = upsertFactRow(body, {
|
||||
rowNum: nextRowNum++,
|
||||
claim: row.fact,
|
||||
kind: row.kind,
|
||||
confidence: row.confidence,
|
||||
@@ -409,7 +381,7 @@ async function phaseCVerify(
|
||||
for (const g of groups) {
|
||||
const localPath = localPathById.get(g.source_id);
|
||||
if (!localPath) continue;
|
||||
const filePath = resolvePageFilePath(localPath, g.source_markdown_slug, g.source_id);
|
||||
const filePath = join(localPath, `${g.source_markdown_slug}.md`);
|
||||
if (!existsSync(filePath)) {
|
||||
mismatches.push(`${g.source_markdown_slug} (file missing)`);
|
||||
continue;
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
* at runtime.
|
||||
*/
|
||||
|
||||
import { chunkText as recursiveChunk } from './recursive.ts';
|
||||
import { chunkText as recursiveChunk, capByEstimatedTokens, DEFAULT_MAX_EST_TOKENS } from './recursive.ts';
|
||||
import { buildQualifiedName } from './qualified-names.ts';
|
||||
import { estimateEmbeddingTokens } from '../cjk.ts';
|
||||
|
||||
// Embed the tree-sitter runtime + per-language grammars as files.
|
||||
// `with { type: 'file' }` returns a path (string) at runtime. Bun bundles
|
||||
@@ -111,7 +112,15 @@ import G_ZIG from '../../assets/wasm/grammars/tree-sitter-zig.wasm' with { type:
|
||||
// chunks get the new columns populated. Without this, the v28 backfill
|
||||
// gives every existing chunk a search_vector but subsequent Layer 5 AST
|
||||
// work would silently no-op.
|
||||
export const CHUNKER_VERSION = 4;
|
||||
//
|
||||
// v5: estimated-token hard cap on AST-path chunks (capCodeChunks). A node
|
||||
// splitLargeNode can't subdivide (giant single-statement function, huge
|
||||
// literal) previously shipped WHOLE regardless of size and could overflow
|
||||
// strict per-request embedding-token limits (local llama-server crashes
|
||||
// past ~2,050 tokens, measured). Mirrors the markdown
|
||||
// chunker's v4 cap; fallback-path chunks are already capped inside
|
||||
// recursiveChunk.
|
||||
export const CHUNKER_VERSION = 5;
|
||||
|
||||
// Lazy-loaded tree-sitter module (v0.22.x API: Parser is default export)
|
||||
let Parser: typeof import('web-tree-sitter') | null = null;
|
||||
@@ -708,7 +717,7 @@ export async function chunkCodeTextFull(
|
||||
if (chunks.length === 0) {
|
||||
return { chunks: fallbackChunks(source, filePath, language, opts), edges: rawEdges };
|
||||
}
|
||||
return { chunks: mergeSmallSiblings(chunks, chunkTarget), edges: rawEdges };
|
||||
return { chunks: capCodeChunks(mergeSmallSiblings(chunks, chunkTarget)), edges: rawEdges };
|
||||
} catch {
|
||||
return { chunks: fallbackChunks(source, filePath, language, opts), edges: [] };
|
||||
} finally {
|
||||
@@ -791,6 +800,33 @@ function mergeSmallSiblings(chunks: CodeChunk[], chunkTarget: number): CodeChunk
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 final safety pass for AST-path chunks: split any chunk whose
|
||||
* ESTIMATED embedding tokens (conservative per-char-class heuristic,
|
||||
* cjk.ts) exceed DEFAULT_MAX_EST_TOKENS. Reaches chunks the AST logic
|
||||
* can't subdivide — splitLargeNode returns [] for nodes with < 2 body
|
||||
* children (giant single-statement functions, huge literals), which
|
||||
* previously shipped whole at any size.
|
||||
*
|
||||
* Split pieces inherit the source chunk's metadata verbatim; start/end
|
||||
* lines become approximate for pieces after the first. Acceptable —
|
||||
* these chunks exist for embedding + retrieval, and the alternative was
|
||||
* an embedding request the server rejects (or worse, crashes on).
|
||||
*/
|
||||
function capCodeChunks(chunks: CodeChunk[]): CodeChunk[] {
|
||||
if (chunks.every((c) => estimateEmbeddingTokens(c.text) <= DEFAULT_MAX_EST_TOKENS)) {
|
||||
return chunks;
|
||||
}
|
||||
const out: CodeChunk[] = [];
|
||||
for (const c of chunks) {
|
||||
const pieces = capByEstimatedTokens(c.text, DEFAULT_MAX_EST_TOKENS);
|
||||
for (const piece of pieces) {
|
||||
out.push({ ...c, text: piece, index: out.length, metadata: { ...c.metadata } });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildMergedChunk(group: CodeChunk[], index: number): CodeChunk {
|
||||
const first = group[0]!;
|
||||
const last = group[group.length - 1]!;
|
||||
|
||||
@@ -17,7 +17,13 @@
|
||||
* Lossless invariant: non-overlapping portions reassemble to original.
|
||||
*/
|
||||
|
||||
import { countCJKAwareWords, CJK_SENTENCE_DELIMITERS, CJK_CLAUSE_DELIMITERS } from '../cjk.ts';
|
||||
import {
|
||||
countCJKAwareWords,
|
||||
CJK_SENTENCE_DELIMITERS,
|
||||
CJK_CLAUSE_DELIMITERS,
|
||||
charEmbedTokenWeight,
|
||||
estimateEmbeddingTokens,
|
||||
} from '../cjk.ts';
|
||||
|
||||
/**
|
||||
* Markdown chunker version. Folded into the per-page chunker_version column
|
||||
@@ -33,8 +39,20 @@ import { countCJKAwareWords, CJK_SENTENCE_DELIMITERS, CJK_CLAUSE_DELIMITERS } fr
|
||||
* re-embed (not re-chunk) so existing pages pick up the wrapper on the
|
||||
* post-upgrade reembed sweep. See
|
||||
* `src/core/contextual-retrieval-service.ts`.
|
||||
*
|
||||
* v4: estimated-token hard cap + whitespace-word undercount fix. The word
|
||||
* pipeline counted a 150-char URL as ONE whitespace word, so URL/phone/
|
||||
* email-dense docs (CJK density < 0.30 → whitespace fallback) produced
|
||||
* 3-4K-char chunks that overflow strict per-request embedding-token
|
||||
* limits (measured: local llama-server crashes past ~2,050 tokens; URL
|
||||
* soup tokenizes at ~1.6 chars/token). Two changes:
|
||||
* 1. countWords() floors the count at ceil(nonWhitespaceChars/6) so a
|
||||
* URL counts roughly per-character, not as one word.
|
||||
* 2. capByEstimatedTokens() final pass guarantees every chunk fits
|
||||
* `maxTokens` (default 1500) under a conservative per-char-class
|
||||
* token estimate, regardless of how word counting misjudged it.
|
||||
*/
|
||||
export const MARKDOWN_CHUNKER_VERSION = 3;
|
||||
export const MARKDOWN_CHUNKER_VERSION = 4;
|
||||
|
||||
const DELIMITERS: string[][] = [
|
||||
['\n\n'], // L0: paragraphs
|
||||
@@ -48,8 +66,20 @@ export interface ChunkOptions {
|
||||
chunkSize?: number; // target words per chunk (default 300)
|
||||
chunkOverlap?: number; // overlap words (default 50)
|
||||
maxChars?: number; // hard cap on any chunk's char length (default 6000)
|
||||
/**
|
||||
* v4: hard cap on any chunk's ESTIMATED embedding tokens (default 1500).
|
||||
* Estimate = conservative per-char-class weights (see cjk.ts
|
||||
* estimateEmbeddingTokens) — deliberately high, so the real tokenizer
|
||||
* count stays below this value. Default leaves headroom for the
|
||||
* contextual-retrieval wrapper (≤ ~630 chars) under a ~2,050-token
|
||||
* per-request embedding server limit.
|
||||
*/
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
/** v4 default for ChunkOptions.maxTokens — see the field doc above. */
|
||||
export const DEFAULT_MAX_EST_TOKENS = 1500;
|
||||
|
||||
export interface TextChunk {
|
||||
text: string;
|
||||
index: number;
|
||||
@@ -73,6 +103,7 @@ export function chunkText(text: string, opts?: ChunkOptions): TextChunk[] {
|
||||
const chunkSize = opts?.chunkSize || 300;
|
||||
const chunkOverlap = opts?.chunkOverlap || 50;
|
||||
const maxChars = opts?.maxChars || 6000;
|
||||
const maxTokens = opts?.maxTokens || DEFAULT_MAX_EST_TOKENS;
|
||||
|
||||
if (!text || text.trim().length === 0) return [];
|
||||
|
||||
@@ -89,8 +120,9 @@ export function chunkText(text: string, opts?: ChunkOptions): TextChunk[] {
|
||||
|
||||
const wordCount = countWords(stripped);
|
||||
if (wordCount <= chunkSize) {
|
||||
// Single-chunk path: still apply the maxChars cap.
|
||||
const capped = capByChars(stripped.trim(), maxChars);
|
||||
// Single-chunk path: still apply the maxChars + maxTokens caps.
|
||||
const capped = capByChars(stripped.trim(), maxChars)
|
||||
.flatMap((t) => capByEstimatedTokens(t, maxTokens));
|
||||
return capped.map((t, i) => ({ text: t, index: i }));
|
||||
}
|
||||
|
||||
@@ -101,9 +133,14 @@ export function chunkText(text: string, opts?: ChunkOptions): TextChunk[] {
|
||||
// v0.32.7: hard char cap. Catches pathological CJK + whitespace-less text
|
||||
// that the word-level pipeline can't bound (a single Chinese paragraph can
|
||||
// exceed 8192 OpenAI embedding tokens at any word count).
|
||||
// v4: estimated-token cap on top — the char cap alone passes token-dense
|
||||
// content (URL soup at ~1.6 chars/token) that overflows strict embedding
|
||||
// server limits.
|
||||
const capped: string[] = [];
|
||||
for (const chunk of withOverlap) {
|
||||
capped.push(...capByChars(chunk.trim(), maxChars));
|
||||
for (const piece of capByChars(chunk.trim(), maxChars)) {
|
||||
capped.push(...capByEstimatedTokens(piece, maxTokens));
|
||||
}
|
||||
}
|
||||
return capped.map((t, i) => ({ text: t, index: i }));
|
||||
}
|
||||
@@ -132,6 +169,68 @@ function capByChars(text: string, maxChars: number): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far back (in chars) the token cap looks for a friendly cut point
|
||||
* before falling back to a hard cut. 300 covers typical rollup/list line
|
||||
* lengths so forced splits land at line starts, not mid-URL.
|
||||
*/
|
||||
const TOKEN_CAP_CUT_LOOKBACK = 300;
|
||||
|
||||
/**
|
||||
* v4: hard-cap a chunk's ESTIMATED embedding tokens. Final safety pass —
|
||||
* runs after capByChars on every chunk, so no upstream miscounting
|
||||
* (whitespace-word fallback, overlap inflation, char-cap survivors) can
|
||||
* emit a chunk past `maxTokens`.
|
||||
*
|
||||
* Cut placement prefers, within the last TOKEN_CAP_CUT_LOOKBACK chars of
|
||||
* the window: a newline, then any whitespace, then a hard cut. This keeps
|
||||
* forced splits off mid-line/mid-URL positions for list-shaped content
|
||||
* and inside code fences. No overlap is added (pieces stay lossless
|
||||
* modulo the trims the char cap already applies).
|
||||
*
|
||||
* @internal exported for the code chunker (code.ts) and tests.
|
||||
*/
|
||||
export function capByEstimatedTokens(text: string, maxTokens: number): string[] {
|
||||
if (text.length === 0) return [];
|
||||
if (estimateEmbeddingTokens(text) <= maxTokens) return [text];
|
||||
|
||||
const out: string[] = [];
|
||||
let start = 0;
|
||||
while (start < text.length) {
|
||||
// Greedily extend the window until the next char would break the cap.
|
||||
// Always take at least one char so the loop makes forward progress.
|
||||
let est = 0;
|
||||
let end = start;
|
||||
while (end < text.length) {
|
||||
const w = charEmbedTokenWeight(text.charCodeAt(end));
|
||||
if (est + w > maxTokens && end > start) break;
|
||||
est += w;
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end < text.length) {
|
||||
const windowStart = Math.max(start + 1, end - TOKEN_CAP_CUT_LOOKBACK);
|
||||
let cut = text.lastIndexOf('\n', end - 1);
|
||||
if (cut < windowStart) {
|
||||
cut = -1;
|
||||
for (let i = end - 1; i >= windowStart; i--) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code === 0x20 || (code >= 0x09 && code <= 0x0d)) {
|
||||
cut = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cut >= windowStart) end = cut + 1;
|
||||
}
|
||||
|
||||
const slice = text.slice(start, end).trim();
|
||||
if (slice.length > 0) out.push(slice);
|
||||
start = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function recursiveSplit(text: string, level: number, target: number): string[] {
|
||||
if (level >= DELIMITERS.length) {
|
||||
// Level 4: split on whitespace
|
||||
@@ -317,7 +416,19 @@ function extractTrailingContext(text: string, targetWords: number): string {
|
||||
* Delegated to src/core/cjk.ts so the slugify whitelist, expansion
|
||||
* detection, and PGLite keyword fallback all agree on what "CJK enough"
|
||||
* means.
|
||||
*
|
||||
* v4: floored at ceil(nonWhitespaceChars/6). The whitespace fallback
|
||||
* counts a 150-char URL as ONE word, so URL/phone/email-dense docs
|
||||
* (whose ASCII mass pushes CJK density below the 0.30 threshold) were
|
||||
* sized at a fraction of their real bulk and merged into 3-4K-char
|
||||
* chunks. The floor makes long whitespace-less runs count roughly
|
||||
* per-character while leaving normal Latin prose untouched (average
|
||||
* English word ≈ 5 chars < 6, so the whitespace count still wins).
|
||||
* Kept local to the chunker — search/expansion.ts keeps the original
|
||||
* countCJKAwareWords semantics for its query-length check.
|
||||
*/
|
||||
function countWords(text: string): number {
|
||||
return countCJKAwareWords(text);
|
||||
const cjkAware = countCJKAwareWords(text);
|
||||
const nonWhitespace = text.replace(/\s/g, '').length;
|
||||
return Math.max(cjkAware, Math.ceil(nonWhitespace / 6));
|
||||
}
|
||||
|
||||
@@ -65,3 +65,64 @@ export function countCJKAwareWords(s: string): number {
|
||||
export function escapeLikePattern(s: string): string {
|
||||
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative per-char-class embedding-token weights (markdown chunker v4).
|
||||
*
|
||||
* Why this exists: the chunker's "word" counting drastically UNDER-counts
|
||||
* whitespace-less ASCII runs (a 150-char URL = 1 whitespace word), so
|
||||
* word-based size targets can emit chunks that overflow an embedding
|
||||
* server's per-request token limit. Measured on a local Qwen3-embedding
|
||||
* llama-server stack:
|
||||
* - URL/phone/email-dense text tokenizes at ~1.6 chars/token
|
||||
* - base64-ish / minified blobs approach ~1.3 chars/token (worst case)
|
||||
* - Korean prose tokenizes NO WORSE than 1 char/token in practice
|
||||
*
|
||||
* Weights are deliberately HIGH (tokens are overestimated) so any cap
|
||||
* based on this estimate is safe against real tokenizers:
|
||||
* - CJK char → 1.0 token (real CJK prose is cheaper)
|
||||
* - other non-space → 0.75 token (≈1.33 chars/token, covers base64)
|
||||
* - whitespace → 0.1 token (mostly folds into neighbor tokens)
|
||||
*/
|
||||
export const EMBED_TOKEN_WEIGHT_CJK = 1.0;
|
||||
export const EMBED_TOKEN_WEIGHT_OTHER = 0.75;
|
||||
export const EMBED_TOKEN_WEIGHT_WS = 0.1;
|
||||
|
||||
/** BMP CJK check by UTF-16 code unit — same ranges as CJK_SLUG_CHARS. */
|
||||
export function isCJKCodeUnit(code: number): boolean {
|
||||
return (
|
||||
(code >= 0x4e00 && code <= 0x9fff) || // Han
|
||||
(code >= 0x3040 && code <= 0x309f) || // Hiragana
|
||||
(code >= 0x30a0 && code <= 0x30ff) || // Katakana
|
||||
(code >= 0xac00 && code <= 0xd7af) // Hangul Syllables
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-code-unit token weight. Unrecognized whitespace (exotic Unicode
|
||||
* spaces) intentionally falls into OTHER — that only overestimates.
|
||||
*/
|
||||
export function charEmbedTokenWeight(code: number): number {
|
||||
if (isCJKCodeUnit(code)) return EMBED_TOKEN_WEIGHT_CJK;
|
||||
if (
|
||||
code === 0x20 || (code >= 0x09 && code <= 0x0d) ||
|
||||
code === 0xa0 || code === 0x3000
|
||||
) {
|
||||
return EMBED_TOKEN_WEIGHT_WS;
|
||||
}
|
||||
return EMBED_TOKEN_WEIGHT_OTHER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenizer-free embedding-token estimate (conservative overestimate).
|
||||
* See weight docs above. Astral chars count as 2 OTHER code units —
|
||||
* another overestimate, which is the safe direction.
|
||||
*/
|
||||
export function estimateEmbeddingTokens(s: string): number {
|
||||
if (s.length === 0) return 0;
|
||||
let est = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
est += charEmbedTokenWeight(s.charCodeAt(i));
|
||||
}
|
||||
return Math.ceil(est);
|
||||
}
|
||||
|
||||
@@ -176,11 +176,9 @@ export async function runExtractFacts(
|
||||
if (legacyCount > 0) {
|
||||
result.guardTriggered = true;
|
||||
result.warnings.push(
|
||||
`extract_facts: ${legacyCount} legacy fact rows pending fence backfill ` +
|
||||
`(row_num IS NULL — v0.31 rows or remote extract_facts deposits that ` +
|
||||
`predate the fence backstop). Run \`gbrain facts fence-backfill\` ` +
|
||||
`(idempotent, re-runnable) before this phase can safely reconcile ` +
|
||||
`fence → DB.`,
|
||||
`extract_facts: ${legacyCount} legacy v0.31 fact rows pending fence backfill. ` +
|
||||
`Run \`gbrain apply-migrations --yes\` to complete v0_32_2 before this phase ` +
|
||||
`can safely reconcile fence → DB.`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
+6
-7
@@ -1739,14 +1739,13 @@ export interface BrainEngine {
|
||||
* single-row supersede flow because fence reconciliation is the canonical
|
||||
* source-of-truth direction, not the consolidator path.
|
||||
*
|
||||
* Insertion runs in a single transaction. A collision on the v51
|
||||
* partial UNIQUE index `(source_id, source_markdown_slug, row_num)`
|
||||
* skips ONLY that row (ON CONFLICT DO NOTHING, #2044) — the rest of
|
||||
* the batch still commits, so a redundant deposit against an
|
||||
* already-indexed fence row is idempotent instead of a hard failure.
|
||||
* Insertion is atomic per call: all rows commit in a single transaction
|
||||
* or none commit (the transaction rolls back on any constraint
|
||||
* violation, e.g. the v51 partial UNIQUE index on
|
||||
* `(source_id, source_markdown_slug, row_num)`).
|
||||
*
|
||||
* Returns the inserted ids in input-order (colliding rows omitted) so
|
||||
* callers can correlate fence-row → DB-id without a separate lookup.
|
||||
* Returns the inserted ids in input-order so callers can correlate
|
||||
* fence-row → DB-id without a separate lookup.
|
||||
*/
|
||||
insertFacts(
|
||||
rows: Array<NewFact & { row_num: number; source_markdown_slug: string }>,
|
||||
|
||||
@@ -218,27 +218,11 @@ export async function writeFactsToFence(
|
||||
}
|
||||
|
||||
// 2. Upsert each fact onto the fence in input order. row_num
|
||||
// monotonically increases, append-only, seeded from the MAX of
|
||||
// the fence and the DB index (#2044): when fence and DB have
|
||||
// diverged (e.g. legacy writes that stamped DB rows against a
|
||||
// fence at a path this code no longer reads), fence-max+1 can
|
||||
// collide with an existing DB row_num, tripping
|
||||
// idx_facts_fence_key and rolling back the whole batch.
|
||||
const dbMaxRows = await engine.executeRaw<{ max: number | string | null }>(
|
||||
`SELECT MAX(row_num) AS max FROM facts
|
||||
WHERE source_id = $1 AND source_markdown_slug = $2`,
|
||||
[target.sourceId, target.slug],
|
||||
);
|
||||
const dbMaxRowNum = Number(dbMaxRows[0]?.max ?? 0) || 0;
|
||||
const fenceMaxRowNum = parseFactsFence(body).facts
|
||||
.reduce((m, f) => Math.max(m, f.rowNum), 0);
|
||||
let nextRowNum = Math.max(fenceMaxRowNum, dbMaxRowNum) + 1;
|
||||
|
||||
// monotonically increases (max-existing + 1 per call, append-only).
|
||||
const assignedRowNums: number[] = [];
|
||||
for (const f of facts) {
|
||||
const validFromStr = (f.validFrom ?? new Date()).toISOString().slice(0, 10);
|
||||
const { body: updated, rowNum } = upsertFactRow(body, {
|
||||
rowNum: nextRowNum++,
|
||||
claim: f.fact,
|
||||
kind: (f.kind ?? 'fact') as 'fact' | 'event' | 'preference' | 'commitment' | 'belief',
|
||||
confidence: f.confidence ?? 1.0,
|
||||
|
||||
@@ -4102,13 +4102,11 @@ export class PGLiteEngine implements BrainEngine {
|
||||
): Promise<{ inserted: number; ids: number[] }> {
|
||||
if (rows.length === 0) return { inserted: 0, ids: [] };
|
||||
|
||||
// Single transaction; per-row INSERTs (not multi-row VALUES) keep the
|
||||
// embedding-vs-no-embedding branching readable; batch sizes are small
|
||||
// (5-30 rows per page in practice) so the loop overhead is negligible
|
||||
// vs the embedding compute cost. #2044: ON CONFLICT DO NOTHING on the
|
||||
// v51 partial UNIQUE index makes a residual fence/DB row_num collision
|
||||
// skip that row instead of rolling back the whole batch (parity with
|
||||
// postgres-engine.ts).
|
||||
// Single transaction so the v51 partial UNIQUE index can roll back the
|
||||
// whole batch on constraint violation. Per-row INSERTs (not multi-row
|
||||
// VALUES) keep the embedding-vs-no-embedding branching readable; batch
|
||||
// sizes are small (5-30 rows per page in practice) so the loop overhead
|
||||
// is negligible vs the embedding compute cost.
|
||||
const ids = await this.db.transaction(async (tx) => {
|
||||
const out: number[] = [];
|
||||
for (const input of rows) {
|
||||
@@ -4151,11 +4149,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
$14, $15,
|
||||
$16, $17, $18, $19,
|
||||
$20
|
||||
)
|
||||
ON CONFLICT (source_id, source_markdown_slug, row_num)
|
||||
WHERE row_num IS NOT NULL
|
||||
DO NOTHING
|
||||
RETURNING id`
|
||||
) RETURNING id`
|
||||
: `INSERT INTO facts (
|
||||
source_id, entity_slug, fact, kind, visibility, notability, context,
|
||||
valid_from, valid_until, source, source_session, confidence,
|
||||
@@ -4169,16 +4163,12 @@ export class PGLiteEngine implements BrainEngine {
|
||||
$15, $16,
|
||||
$17, $18, $19, $20,
|
||||
$21
|
||||
)
|
||||
ON CONFLICT (source_id, source_markdown_slug, row_num)
|
||||
WHERE row_num IS NOT NULL
|
||||
DO NOTHING
|
||||
RETURNING id`,
|
||||
) RETURNING id`,
|
||||
embedStr === null
|
||||
? [ctx.source_id, entitySlug, input.fact, kind, visibility, notability, context, validFrom, validUntil, input.source, sourceSession, confidence, embeddedAt, input.row_num, input.source_markdown_slug, claimMetric, claimValue, claimUnit, claimPeriod, eventType]
|
||||
: [ctx.source_id, entitySlug, input.fact, kind, visibility, notability, context, validFrom, validUntil, input.source, sourceSession, confidence, embedStr, embeddedAt, input.row_num, input.source_markdown_slug, claimMetric, claimValue, claimUnit, claimPeriod, eventType],
|
||||
);
|
||||
if (ins.rows[0]) out.push(ins.rows[0].id);
|
||||
out.push(ins.rows[0].id);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -4302,12 +4302,10 @@ export class PostgresEngine implements BrainEngine {
|
||||
// ONCE per process so the cast matches the actual column type
|
||||
// (halfvec vs vector). The probe is cached after first call.
|
||||
const castSuffix = await this.resolveFactsEmbeddingCast();
|
||||
// Single transaction; per-row INSERTs (not multi-row VALUES) keep the
|
||||
// embedding-vs-no-embedding branching readable; batch sizes are small
|
||||
// (5-30 rows per page in practice). #2044: ON CONFLICT DO NOTHING on
|
||||
// the v51 partial UNIQUE index makes a residual fence/DB row_num
|
||||
// collision skip that row instead of rolling back the whole batch —
|
||||
// the fence stays system-of-record and reconciliation catches up.
|
||||
// Single transaction so the v51 partial UNIQUE index can roll back
|
||||
// the whole batch on constraint violation. Per-row INSERTs (not
|
||||
// multi-row VALUES) keep the embedding-vs-no-embedding branching
|
||||
// readable; batch sizes are small (5-30 rows per page in practice).
|
||||
// No supersede flow in this path — fence reconciliation is the
|
||||
// canonical source-of-truth direction, not the consolidator path.
|
||||
const ids = await sql.begin(async (tx) => {
|
||||
@@ -4348,13 +4346,9 @@ export class PostgresEngine implements BrainEngine {
|
||||
${input.row_num}, ${input.source_markdown_slug},
|
||||
${claimMetric}, ${claimValue}, ${claimUnit}, ${claimPeriod},
|
||||
${eventType}
|
||||
)
|
||||
ON CONFLICT (source_id, source_markdown_slug, row_num)
|
||||
WHERE row_num IS NOT NULL
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
) RETURNING id
|
||||
`;
|
||||
if (ins[0]) out.push(Number(ins[0].id));
|
||||
out.push(Number(ins[0].id));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -15,19 +15,22 @@ import { describe, test, expect } from 'bun:test';
|
||||
import { CHUNKER_VERSION } from '../src/core/chunkers/code.ts';
|
||||
|
||||
describe('Layer 12 — CHUNKER_VERSION constant', () => {
|
||||
test('bumped to 4 for Cathedral II', () => {
|
||||
test('bumped to 5 for the estimated-token hard cap', () => {
|
||||
// v3: v0.19.0 Chonkie parity (tokenizer + small-sibling merge).
|
||||
// v4: v0.20.0 Cathedral II (qualified names + parent scope + doc_comment
|
||||
// + fence extraction + chunk-grain FTS). Folded into content_hash
|
||||
// so any bump forces clean re-chunks on next sync.
|
||||
expect(CHUNKER_VERSION).toBe(4);
|
||||
// v5: estimated-token hard cap on AST-path chunks (capCodeChunks) so
|
||||
// un-subdividable giant nodes can't overflow strict embedding
|
||||
// server token limits.
|
||||
expect(CHUNKER_VERSION).toBe(5);
|
||||
});
|
||||
|
||||
test('is stable across imports (not recomputed at call time)', async () => {
|
||||
const a = (await import('../src/core/chunkers/code.ts')).CHUNKER_VERSION;
|
||||
const b = (await import('../src/core/chunkers/code.ts')).CHUNKER_VERSION;
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe(4);
|
||||
expect(a).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import { describe, test, expect } from 'bun:test';
|
||||
import { chunkCodeText, detectCodeLanguage, CHUNKER_VERSION } from '../../src/core/chunkers/code.ts';
|
||||
|
||||
describe('CHUNKER_VERSION', () => {
|
||||
test('v0.20.0 Cathedral II Layer 12 bumped to 4', () => {
|
||||
expect(CHUNKER_VERSION).toBe(4);
|
||||
test('v5: estimated-token hard cap on AST-path chunks', () => {
|
||||
expect(CHUNKER_VERSION).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -135,13 +135,14 @@ describe('Recursive Text Chunker', () => {
|
||||
});
|
||||
|
||||
describe('CJK chunking (v0.32.7)', () => {
|
||||
test('MARKDOWN_CHUNKER_VERSION is 3', async () => {
|
||||
test('MARKDOWN_CHUNKER_VERSION is 4', async () => {
|
||||
// v0.40.3.0: bumped 2→3 to signal the post-upgrade reembed sweep that
|
||||
// contextual retrieval wrapping is now applied at embed time. Chunk
|
||||
// boundaries themselves are unchanged; the bump forces re-embed for
|
||||
// pages where chunker_version < 3.
|
||||
// contextual retrieval wrapping is now applied at embed time.
|
||||
// v4: estimated-token hard cap + whitespace-word undercount floor
|
||||
// (URL-dense docs produced chunks past strict embedding server token
|
||||
// limits). Boundary change → forces re-chunk for chunker_version < 4.
|
||||
const mod = await import('../../src/core/chunkers/recursive.ts');
|
||||
expect(mod.MARKDOWN_CHUNKER_VERSION).toBe(3);
|
||||
expect(mod.MARKDOWN_CHUNKER_VERSION).toBe(4);
|
||||
});
|
||||
|
||||
test('long pure-Chinese paragraph splits into multiple chunks', () => {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Markdown chunker v4 / code chunker v5 — estimated-token hard cap
|
||||
* regression tests.
|
||||
*
|
||||
* Reproduces a field failure: a local llama-server embedding backend
|
||||
* (`-ub 2048`) crashes deterministically (trace/BPT trap → EOF at the
|
||||
* client) when a single chunk exceeds ~2,050 real tokens. Two content
|
||||
* shapes triggered it:
|
||||
*
|
||||
* 1. Korean docs carrying one long source URL per line.
|
||||
* The URLs' ASCII mass pushes CJK density below 0.30, flipping
|
||||
* countCJKAwareWords to whitespace counting, where a 150-char URL
|
||||
* counts as ONE word → chunks ballooned to 3-4K chars ≈ 2,000+
|
||||
* real tokens (URL soup tokenizes at ~1.6 chars/token).
|
||||
*
|
||||
* 2. Large JSON code blocks (~7K chars) that the word pipeline
|
||||
* undercounts the same way (few whitespace tokens).
|
||||
*
|
||||
* The fix: every emitted chunk must satisfy
|
||||
* estimateEmbeddingTokens(chunk) <= maxTokens (default 1500)
|
||||
* where the estimate deliberately OVERSTATES real tokenizer counts.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { chunkText, capByEstimatedTokens, DEFAULT_MAX_EST_TOKENS } from '../../src/core/chunkers/recursive.ts';
|
||||
import { chunkCodeText } from '../../src/core/chunkers/code.ts';
|
||||
import { estimateEmbeddingTokens } from '../../src/core/cjk.ts';
|
||||
|
||||
/** Synthesize the failing shape: Korean rollup lines each ending in a long Notion URL. */
|
||||
function urlDenseKoreanRollup(lines: number): string {
|
||||
const out: string[] = ['# 링크가 줄마다 붙는 한국어 예시 문서', ''];
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const hex32 = (i * 2654435761 >>> 0).toString(16).padStart(8, '0').repeat(4);
|
||||
out.push(
|
||||
`- **항목 ${i}**: 이 줄은 청커 동작 검증을 위한 의미 없는 한국어 예시 문장입니다 · 전화 000-0000-${String(1000 + i)} · ` +
|
||||
`이메일 user${i}@example.com · 링크: https://docs.example.com/pages/${hex32}?v=abcdef0123456789&ref=sample`,
|
||||
);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** Synthesize a large pretty-printed JSON block with CJK values. */
|
||||
function bigJsonBlock(targetChars: number): string {
|
||||
const entries: string[] = [];
|
||||
let i = 0;
|
||||
let len = 0;
|
||||
while (len < targetChars) {
|
||||
const row =
|
||||
` "item_${i}": { "name": "예시-${i}", "url": "https://example.com/api/v2/items/${i}?token=abc${i}def", "qty": ${i % 100}, "memo": "한국어 값이 섞인 예시 데이터" }`;
|
||||
entries.push(row);
|
||||
len += row.length;
|
||||
i++;
|
||||
}
|
||||
return `{\n${entries.join(',\n')}\n}`;
|
||||
}
|
||||
|
||||
describe('v4 estimated-token cap — URL-dense Korean doc (field-failure shape)', () => {
|
||||
test('every chunk stays under the estimated-token cap', () => {
|
||||
const md = urlDenseKoreanRollup(60);
|
||||
const chunks = chunkText(md);
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
for (const c of chunks) {
|
||||
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(DEFAULT_MAX_EST_TOKENS);
|
||||
}
|
||||
});
|
||||
|
||||
test('no chunk reaches the measured 3K-char danger zone for URL soup', () => {
|
||||
const md = urlDenseKoreanRollup(60);
|
||||
const chunks = chunkText(md);
|
||||
// 1500 est tokens at the OTHER weight (0.75/char) bounds chunks to
|
||||
// ~2,000 chars for pure ASCII — well under the ~3,300 chars where
|
||||
// URL-dense content crosses ~2,050 real tokens (1.6 chars/token).
|
||||
for (const c of chunks) {
|
||||
expect(c.text.length).toBeLessThanOrEqual(2600);
|
||||
}
|
||||
});
|
||||
|
||||
test('content is preserved (no lines dropped by the cap)', () => {
|
||||
const md = urlDenseKoreanRollup(60);
|
||||
const chunks = chunkText(md);
|
||||
const joined = chunks.map((c) => c.text).join('\n');
|
||||
// Spot-check first / middle / last rollup lines survive chunking.
|
||||
for (const marker of ['항목 0', '항목 30', '항목 59']) {
|
||||
expect(joined).toContain(marker);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('v4 estimated-token cap — large JSON blocks', () => {
|
||||
test('7K-char pretty JSON through the prose path stays under the cap', () => {
|
||||
const md = `설정 파일 원문 보존:\n\n\`\`\`\n${bigJsonBlock(7000)}\n\`\`\`\n`;
|
||||
const chunks = chunkText(md);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const c of chunks) {
|
||||
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(DEFAULT_MAX_EST_TOKENS);
|
||||
}
|
||||
});
|
||||
|
||||
test('7K-char minified JSON (single whitespace-less token) stays under the cap', () => {
|
||||
const minified = bigJsonBlock(7000).replace(/\n\s*/g, '');
|
||||
const chunks = chunkText(minified);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const c of chunks) {
|
||||
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(DEFAULT_MAX_EST_TOKENS);
|
||||
}
|
||||
});
|
||||
|
||||
test('json fence via the code chunker stays under the cap (+header slack)', async () => {
|
||||
const chunks = await chunkCodeText(bigJsonBlock(7000), 'fence.json');
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
for (const c of chunks) {
|
||||
// buildChunk prepends a short "[JSON] fence.json:…" header AFTER the
|
||||
// body-level cap; allow ~60 est tokens of header slack. Real-token
|
||||
// safety margin (2,050 − overestimated 1,500) absorbs this easily.
|
||||
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(DEFAULT_MAX_EST_TOKENS + 60);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('v4 word-count floor — behavior preserved for normal content', () => {
|
||||
test('Latin prose chunking is unchanged by the floor (avg word < 6 chars)', () => {
|
||||
const prose = Array.from({ length: 120 }, (_, i) =>
|
||||
`This is sentence number ${i} and it talks about ordinary things in plain words.`,
|
||||
).join(' ');
|
||||
const chunks = chunkText(prose);
|
||||
// Historical behavior: ~1,560 whitespace words → multiple ~300-word chunks.
|
||||
expect(chunks.length).toBeGreaterThan(3);
|
||||
for (const c of chunks) {
|
||||
const words = c.text.split(/\s+/).length;
|
||||
expect(words).toBeLessThanOrEqual(300 * 1.5 + 50); // merge cap + overlap
|
||||
}
|
||||
});
|
||||
|
||||
test('Korean prose (CJK-dense, no URLs) never triggers the token cap', () => {
|
||||
const prose = Array.from({ length: 80 }, (_, i) =>
|
||||
`이 문장은 순수 한국어 산문의 청킹 동작을 확인하기 위한 ${i}번째 예시 문장입니다.`,
|
||||
).join(' ');
|
||||
const chunks = chunkText(prose);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const c of chunks) {
|
||||
// CJK-dense chunks are char-counted (≈450 max) — nowhere near 1500.
|
||||
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(700);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('capByEstimatedTokens unit behavior', () => {
|
||||
test('returns input unchanged when under the cap', () => {
|
||||
expect(capByEstimatedTokens('short text', 1500)).toEqual(['short text']);
|
||||
expect(capByEstimatedTokens('', 1500)).toEqual([]);
|
||||
});
|
||||
|
||||
test('prefers newline cut points within the lookback window', () => {
|
||||
const line = 'x'.repeat(100);
|
||||
const text = Array.from({ length: 40 }, () => line).join('\n');
|
||||
const pieces = capByEstimatedTokens(text, 1000);
|
||||
expect(pieces.length).toBeGreaterThan(1);
|
||||
for (const p of pieces) {
|
||||
// Every piece should be whole lines (multiples of the 100-char line).
|
||||
for (const l of p.split('\n')) {
|
||||
expect(l).toBe(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('makes forward progress on whitespace-less input (hard cut)', () => {
|
||||
const blob = 'a'.repeat(10_000);
|
||||
const pieces = capByEstimatedTokens(blob, 1000);
|
||||
expect(pieces.length).toBeGreaterThan(1);
|
||||
expect(pieces.join('')).toBe(blob);
|
||||
for (const p of pieces) {
|
||||
expect(estimateEmbeddingTokens(p)).toBeLessThanOrEqual(1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateEmbeddingTokens — weight sanity', () => {
|
||||
test('overestimates URL-dense ASCII (0.75/char ≥ measured ~0.63/char)', () => {
|
||||
const url = 'https://docs.example.com/pages/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4?v=abc&ref=sample';
|
||||
const est = estimateEmbeddingTokens(url);
|
||||
expect(est).toBeGreaterThanOrEqual(Math.floor(url.length * 0.7));
|
||||
});
|
||||
|
||||
test('counts CJK at 1 token/char', () => {
|
||||
expect(estimateEmbeddingTokens('가나다라마')).toBe(5);
|
||||
});
|
||||
|
||||
test('whitespace is nearly free', () => {
|
||||
expect(estimateEmbeddingTokens(' \n\t ')).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('empty string is 0', () => {
|
||||
expect(estimateEmbeddingTokens('')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -304,9 +304,7 @@ describe('runExtractFacts — empty-fence guard (Codex R2-#7)', () => {
|
||||
expect(r.legacyRowsPending).toBe(1);
|
||||
expect(r.factsInserted).toBe(0);
|
||||
expect(r.factsDeleted).toBe(0);
|
||||
// #1867: the remedy hint points at the re-runnable backfill command,
|
||||
// not the one-shot v0_32_2 migration (which the ledger never re-runs).
|
||||
expect(r.warnings.some(w => w.includes('gbrain facts fence-backfill'))).toBe(true);
|
||||
expect(r.warnings.some(w => w.includes('apply-migrations'))).toBe(true);
|
||||
|
||||
// Legacy row was NOT touched.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* #1867 — `gbrain facts fence-backfill` command tests.
|
||||
*
|
||||
* The command re-runs the (idempotent) v0_32_2 phase B on demand so
|
||||
* row_num-NULL backlogs — remote extract_facts deposits that predate
|
||||
* the fence-write backstop — can be cleared without re-running the
|
||||
* one-shot migration. Real PGLite + real tempdir filesystem.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { runFactsCommand } from '../src/commands/facts.ts';
|
||||
import { phaseBFenceFacts } from '../src/commands/migrations/v0_32_2.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let brainDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
try {
|
||||
if (brainDir) rmSync(brainDir, { recursive: true, force: true });
|
||||
} catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
brainDir = mkdtempSync(join(tmpdir(), 'facts-backfill-cmd-test-'));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (engine as any).db.query('DELETE FROM facts');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (engine as any).db.query(
|
||||
`UPDATE sources SET local_path = $1 WHERE id = 'default'`,
|
||||
[brainDir],
|
||||
);
|
||||
});
|
||||
|
||||
async function seedLegacyFact(fact: string): Promise<void> {
|
||||
// The row_num-NULL shape a remote extract_facts deposit leaves behind
|
||||
// when it lands via the legacy DB-only path.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
|
||||
valid_from, source, confidence)
|
||||
VALUES ('default', 'people/alice', $1, 'fact', 'private', 'medium',
|
||||
now(), 'mcp:extract_facts', 1.0)`,
|
||||
[fact],
|
||||
);
|
||||
}
|
||||
|
||||
describe('gbrain facts fence-backfill', () => {
|
||||
test('fences row_num-NULL rows and stamps the DB', async () => {
|
||||
await seedLegacyFact('Deposited remotely');
|
||||
|
||||
await runFactsCommand(engine, ['fence-backfill']);
|
||||
|
||||
// The fence exists on disk with the claim.
|
||||
const filePath = join(brainDir, 'people/alice.md');
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, 'utf-8')).toContain('Deposited remotely');
|
||||
|
||||
// The backlog is cleared: no row_num-NULL rows remain.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = await (engine as any).db.query(
|
||||
'SELECT row_num, source_markdown_slug FROM facts',
|
||||
);
|
||||
expect(rows.rows).toHaveLength(1);
|
||||
expect(rows.rows[0].row_num).toBe(1);
|
||||
expect(rows.rows[0].source_markdown_slug).toBe('people/alice');
|
||||
});
|
||||
|
||||
test('re-run is a no-op (idempotent)', async () => {
|
||||
await seedLegacyFact('Deposited remotely');
|
||||
await runFactsCommand(engine, ['fence-backfill']);
|
||||
await runFactsCommand(engine, ['fence-backfill']);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = await (engine as any).db.query('SELECT id FROM facts');
|
||||
expect(rows.rows).toHaveLength(1);
|
||||
const body = readFileSync(join(brainDir, 'people/alice.md'), 'utf-8');
|
||||
expect(body.match(/Deposited remotely/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('--dry-run reports without writing', async () => {
|
||||
await seedLegacyFact('Deposited remotely');
|
||||
await runFactsCommand(engine, ['fence-backfill', '--dry-run']);
|
||||
|
||||
expect(existsSync(join(brainDir, 'people/alice.md'))).toBe(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = await (engine as any).db.query(
|
||||
'SELECT row_num FROM facts',
|
||||
);
|
||||
expect(rows.rows[0].row_num).toBeNull();
|
||||
});
|
||||
|
||||
test('diverged page: appends past the DB row_num max instead of colliding (#2044 class)', async () => {
|
||||
// The #2044 divergence shape the backfill must survive: the DB already
|
||||
// holds stamped rows 1..3 for the slug (legacy wrong-path fence write),
|
||||
// but the fence at the resolved path is missing. Fence-max+1 (= 1) would
|
||||
// collide with the stamped rows on the post-rename UPDATE, failing the
|
||||
// page and leaving the renamed fence disagreeing with the DB.
|
||||
for (let n = 1; n <= 3; n++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
|
||||
valid_from, source, confidence, row_num, source_markdown_slug)
|
||||
VALUES ('default', 'people/alice', $1, 'fact', 'private', 'medium',
|
||||
now(), 'mcp:extract_facts', 1.0, $2, 'people/alice')`,
|
||||
[`stamped ${n}`, n],
|
||||
);
|
||||
}
|
||||
await seedLegacyFact('Deposited remotely');
|
||||
|
||||
const result = await phaseBFenceFacts(engine, { dryRun: false });
|
||||
expect(result.status).toBe('complete');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = await (engine as any).db.query(
|
||||
`SELECT row_num FROM facts WHERE fact = 'Deposited remotely'`,
|
||||
);
|
||||
expect(rows.rows[0].row_num).toBe(4);
|
||||
const body = readFileSync(join(brainDir, 'people/alice.md'), 'utf-8');
|
||||
expect(body).toContain('| 4 | Deposited remotely |');
|
||||
});
|
||||
});
|
||||
@@ -292,49 +292,6 @@ describe('lookupSourceLocalPath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeFactsToFence — fence/DB divergence (#2044)', () => {
|
||||
test('seeds row_num past the DB max when the fence lags the DB', async () => {
|
||||
// Simulate the divergence class from #2044: DB rows were stamped with
|
||||
// row_nums against a fence written at a path this code no longer reads
|
||||
// (e.g. the pre-"Local patch 2026-06-11" wrong-path writes). The page
|
||||
// on disk has NO fence, but the DB already holds row_num 1..3 for the
|
||||
// slug. Pre-fix, the next deposit re-assigned row_num=1 from fence
|
||||
// text alone and the whole insertFacts batch failed on
|
||||
// idx_facts_fence_key.
|
||||
for (let n = 1; n <= 3; n++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability,
|
||||
valid_from, source, confidence, row_num, source_markdown_slug)
|
||||
VALUES ('default', 'people/dana', $1, 'fact', 'private', 'medium',
|
||||
now(), 'mcp:extract_facts', 1.0, $2, 'people/dana')`,
|
||||
[`old claim ${n}`, n],
|
||||
);
|
||||
}
|
||||
|
||||
const result = await writeFactsToFence(
|
||||
engine,
|
||||
{ sourceId: 'default', localPath: brainDir, slug: 'people/dana' },
|
||||
[baseInput({ fact: 'second deposit' })],
|
||||
);
|
||||
|
||||
expect(result.fenceWriteFailed).toBeUndefined();
|
||||
expect(result.inserted).toBe(1);
|
||||
|
||||
// The new row landed PAST the DB max, not at fence-max+1 (= 1).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = await (engine as any).db.query(
|
||||
'SELECT row_num FROM facts WHERE id = $1',
|
||||
[result.ids[0]],
|
||||
);
|
||||
expect(rows.rows[0].row_num).toBe(4);
|
||||
|
||||
// And the on-disk fence carries the same row_num — fence and DB agree.
|
||||
const body = readFileSync(join(brainDir, 'people/dana.md'), 'utf-8');
|
||||
expect(body).toContain('| 4 | second deposit |');
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup any leftover tempdirs after the whole suite.
|
||||
afterAll(() => {
|
||||
// No-op: each test cleaned up via the beforeEach; this is a safety net.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - Batch insert N rows persists row_num + source_markdown_slug
|
||||
* - Empty batch is a no-op
|
||||
* - Returns ids in input-order
|
||||
* - v51 partial UNIQUE collision skips only the colliding row (#2044)
|
||||
* - v51 partial UNIQUE index rolls back the whole batch on a collision
|
||||
* - deleteFactsForPage scopes by (source_id, source_markdown_slug);
|
||||
* never touches other pages or pre-v51 NULL-source_markdown_slug rows
|
||||
* - deleteFactsForPage on an empty page returns deleted:0 (idempotent)
|
||||
@@ -135,29 +135,30 @@ describe('engine.insertFacts — batch insert', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('v51 partial UNIQUE collision skips ONLY the colliding row (#2044)', async () => {
|
||||
test('v51 partial UNIQUE index rolls back the whole batch on collision', async () => {
|
||||
// Seed row #1 first.
|
||||
await engine.insertFacts([fixtureFact(1, { fact: 'seeded' })], { source_id: 'default' });
|
||||
|
||||
// Batch-insert rows that include a colliding row_num=1. Pre-#2044 this
|
||||
// threw and rolled back the whole batch, making a second remote
|
||||
// extract_facts deposit to an already-fenced page a hard failure. Now
|
||||
// ON CONFLICT DO NOTHING skips the colliding row and keeps the rest.
|
||||
const result = await engine.insertFacts(
|
||||
[
|
||||
fixtureFact(2, { fact: 'second' }),
|
||||
fixtureFact(1, { fact: 'collides' }), // row_num=1 on same (source_id, source_markdown_slug)
|
||||
fixtureFact(3, { fact: 'third' }),
|
||||
],
|
||||
{ source_id: 'default' },
|
||||
);
|
||||
expect(result.inserted).toBe(2);
|
||||
expect(result.ids).toHaveLength(2);
|
||||
// Now try to batch-insert rows that include a colliding row_num=1.
|
||||
let threw = false;
|
||||
try {
|
||||
await engine.insertFacts(
|
||||
[
|
||||
fixtureFact(2, { fact: 'second' }),
|
||||
fixtureFact(1, { fact: 'collides' }), // row_num=1 on same (source_id, source_markdown_slug)
|
||||
fixtureFact(3, { fact: 'third' }),
|
||||
],
|
||||
{ source_id: 'default' },
|
||||
);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// The seeded row survives untouched; the colliding claim is skipped.
|
||||
// Verify the transaction rolled back — only the seeded row should remain.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rows = await (engine as any).db.query('SELECT fact FROM facts ORDER BY id');
|
||||
expect(rows.rows.map((r: { fact: string }) => r.fact)).toEqual(['seeded', 'second', 'third']);
|
||||
expect(rows.rows.map((r: { fact: string }) => r.fact)).toEqual(['seeded']);
|
||||
});
|
||||
|
||||
test('different source_markdown_slug values DO NOT collide on the same row_num', async () => {
|
||||
|
||||
Reference in New Issue
Block a user