mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 01:12:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58683eb9ac |
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { runPhaseCalibrationProfile } from '../core/cycle/calibration-profile.ts';
|
||||
import { resolveCalibrationHolder, runPhaseCalibrationProfile } from '../core/cycle/calibration-profile.ts';
|
||||
import { sourceScopeOpts, type OperationContext } from '../core/operations.ts';
|
||||
import type { GBrainConfig } from '../core/config.ts';
|
||||
import { GBrainError } from '../core/types.ts';
|
||||
@@ -167,7 +167,7 @@ export async function runCalibration(
|
||||
config: GBrainConfig,
|
||||
): Promise<void> {
|
||||
const { opts } = parseArgs(args);
|
||||
const holder = opts.holder ?? 'garry';
|
||||
const holder = await resolveCalibrationHolder(engine, opts.holder);
|
||||
// Resolve --source / GBRAIN_SOURCE / .gbrain-source so the (now reachable, #2035)
|
||||
// calibration command targets the right source in a multi-source brain instead
|
||||
// of always reading `default`. No signal → 'default' (prior behavior).
|
||||
@@ -253,14 +253,14 @@ export async function getCalibrationProfileOp(
|
||||
ctx: OperationContext,
|
||||
params: { holder?: string },
|
||||
): Promise<CalibrationProfileRow | null> {
|
||||
const holder = params.holder ?? 'garry';
|
||||
if (typeof holder !== 'string' || holder.length === 0) {
|
||||
if (params.holder !== undefined && (typeof params.holder !== 'string' || params.holder.length === 0)) {
|
||||
throw new GBrainError(
|
||||
'INVALID_HOLDER',
|
||||
'get_calibration_profile.holder must be a non-empty string',
|
||||
'pass holder="<slug>" or omit to default to "garry"',
|
||||
'pass holder="<slug>" or omit to default to the calibration.user_holder config (then "garry")',
|
||||
);
|
||||
}
|
||||
const holder = await resolveCalibrationHolder(ctx.engine, params.holder);
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
return getLatestProfile(ctx.engine, { holder, ...scope });
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@
|
||||
* at runtime.
|
||||
*/
|
||||
|
||||
import { chunkText as recursiveChunk, capByEstimatedTokens, DEFAULT_MAX_EST_TOKENS } from './recursive.ts';
|
||||
import { chunkText as recursiveChunk } 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
|
||||
@@ -112,15 +111,7 @@ 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.
|
||||
//
|
||||
// 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;
|
||||
export const CHUNKER_VERSION = 4;
|
||||
|
||||
// Lazy-loaded tree-sitter module (v0.22.x API: Parser is default export)
|
||||
let Parser: typeof import('web-tree-sitter') | null = null;
|
||||
@@ -717,7 +708,7 @@ export async function chunkCodeTextFull(
|
||||
if (chunks.length === 0) {
|
||||
return { chunks: fallbackChunks(source, filePath, language, opts), edges: rawEdges };
|
||||
}
|
||||
return { chunks: capCodeChunks(mergeSmallSiblings(chunks, chunkTarget)), edges: rawEdges };
|
||||
return { chunks: mergeSmallSiblings(chunks, chunkTarget), edges: rawEdges };
|
||||
} catch {
|
||||
return { chunks: fallbackChunks(source, filePath, language, opts), edges: [] };
|
||||
} finally {
|
||||
@@ -800,33 +791,6 @@ 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,13 +17,7 @@
|
||||
* Lossless invariant: non-overlapping portions reassemble to original.
|
||||
*/
|
||||
|
||||
import {
|
||||
countCJKAwareWords,
|
||||
CJK_SENTENCE_DELIMITERS,
|
||||
CJK_CLAUSE_DELIMITERS,
|
||||
charEmbedTokenWeight,
|
||||
estimateEmbeddingTokens,
|
||||
} from '../cjk.ts';
|
||||
import { countCJKAwareWords, CJK_SENTENCE_DELIMITERS, CJK_CLAUSE_DELIMITERS } from '../cjk.ts';
|
||||
|
||||
/**
|
||||
* Markdown chunker version. Folded into the per-page chunker_version column
|
||||
@@ -39,20 +33,8 @@ import {
|
||||
* 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 = 4;
|
||||
export const MARKDOWN_CHUNKER_VERSION = 3;
|
||||
|
||||
const DELIMITERS: string[][] = [
|
||||
['\n\n'], // L0: paragraphs
|
||||
@@ -66,20 +48,8 @@ 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;
|
||||
@@ -103,7 +73,6 @@ 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 [];
|
||||
|
||||
@@ -120,9 +89,8 @@ export function chunkText(text: string, opts?: ChunkOptions): TextChunk[] {
|
||||
|
||||
const wordCount = countWords(stripped);
|
||||
if (wordCount <= chunkSize) {
|
||||
// Single-chunk path: still apply the maxChars + maxTokens caps.
|
||||
const capped = capByChars(stripped.trim(), maxChars)
|
||||
.flatMap((t) => capByEstimatedTokens(t, maxTokens));
|
||||
// Single-chunk path: still apply the maxChars cap.
|
||||
const capped = capByChars(stripped.trim(), maxChars);
|
||||
return capped.map((t, i) => ({ text: t, index: i }));
|
||||
}
|
||||
|
||||
@@ -133,14 +101,9 @@ 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) {
|
||||
for (const piece of capByChars(chunk.trim(), maxChars)) {
|
||||
capped.push(...capByEstimatedTokens(piece, maxTokens));
|
||||
}
|
||||
capped.push(...capByChars(chunk.trim(), maxChars));
|
||||
}
|
||||
return capped.map((t, i) => ({ text: t, index: i }));
|
||||
}
|
||||
@@ -169,68 +132,6 @@ 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
|
||||
@@ -416,19 +317,7 @@ 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 {
|
||||
const cjkAware = countCJKAwareWords(text);
|
||||
const nonWhitespace = text.replace(/\s/g, '').length;
|
||||
return Math.max(cjkAware, Math.ceil(nonWhitespace / 6));
|
||||
return countCJKAwareWords(text);
|
||||
}
|
||||
|
||||
@@ -65,64 +65,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -928,6 +928,10 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
// Emotional weight (v0.29)
|
||||
'emotional_weight.high_tags',
|
||||
'emotional_weight.user_holder',
|
||||
// Calibration holder (#1726): persistent default for the nightly
|
||||
// calibration_profile phase + `gbrain calibration`, symmetric with
|
||||
// emotional_weight.user_holder. Falls back to 'garry' when unset.
|
||||
'calibration.user_holder',
|
||||
// Cycle phase config
|
||||
'cycle.grade_takes.write_gstack_learnings',
|
||||
// Content sanity (v0.41)
|
||||
|
||||
@@ -96,7 +96,7 @@ export type PatternStatementsGenerator = (input: {
|
||||
export type BiasTagsGenerator = (patterns: string[]) => Promise<string[]>;
|
||||
|
||||
export interface CalibrationProfileOpts extends BasePhaseOpts {
|
||||
/** Holder to generate the profile for. Default 'garry'. */
|
||||
/** Holder to generate the profile for. Default: `calibration.user_holder` config, then 'garry'. */
|
||||
holder?: string;
|
||||
/** Inject the patterns generator (tests). */
|
||||
patternsGenerator?: PatternStatementsGenerator;
|
||||
@@ -194,6 +194,26 @@ export function parseBiasTagsOutput(raw: string): string[] {
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* #1726: resolve the calibration holder. Explicit param wins, then the
|
||||
* persistent `calibration.user_holder` config key (symmetric with
|
||||
* emotional_weight.user_holder), then the legacy 'garry' default. Fail-open:
|
||||
* a missing config table / mock engine without getConfig falls through.
|
||||
*/
|
||||
export async function resolveCalibrationHolder(
|
||||
engine: BrainEngine,
|
||||
explicit?: string,
|
||||
): Promise<string> {
|
||||
if (explicit) return explicit;
|
||||
try {
|
||||
const configured = await engine.getConfig('calibration.user_holder');
|
||||
if (configured && configured.trim().length > 0) return configured.trim();
|
||||
} catch {
|
||||
// Config unavailable — use the legacy default.
|
||||
}
|
||||
return 'garry';
|
||||
}
|
||||
|
||||
/** Pick the "loudest" pattern slot for the template fallback. */
|
||||
function pickFallbackSlots(scorecard: TakesScorecard): PatternStatementSlots {
|
||||
if (!scorecard || scorecard.resolved === 0) {
|
||||
@@ -227,7 +247,7 @@ class CalibrationProfilePhase extends BaseCyclePhase {
|
||||
_ctx: OperationContext,
|
||||
opts: CalibrationProfileOpts,
|
||||
): Promise<{ summary: string; details: Record<string, unknown>; status?: PhaseStatus }> {
|
||||
const holder = opts.holder ?? 'garry';
|
||||
const holder = await resolveCalibrationHolder(engine, opts.holder);
|
||||
const promptVersion = opts.promptVersion ?? CALIBRATION_PROFILE_PROMPT_VERSION;
|
||||
const modelId = opts.model ?? TIER_DEFAULTS.reasoning;
|
||||
const gradeCompletion = opts.gradeCompletion ?? 1.0;
|
||||
|
||||
@@ -68,11 +68,14 @@ export async function extractTimelineFromMeetings(
|
||||
// 1. Fetch all meeting pages (one round-trip).
|
||||
const sourceFilter = opts.sourceIdFilter ? `AND source_id = $1` : '';
|
||||
const meetingParams = opts.sourceIdFilter ? [opts.sourceIdFilter] : [];
|
||||
// #2109: gbrain-base-v2's unify-types catch-all retypes meeting pages to
|
||||
// `note` with frontmatter.legacy_type = 'meeting'. Match both spellings so
|
||||
// the extractor keeps working on migrated (v2) brains, not just v1 ones.
|
||||
const meetings = await engine.executeRaw<MeetingRow>(
|
||||
`SELECT slug, source_id, title, effective_date, updated_at,
|
||||
compiled_truth, COALESCE(timeline, '') AS timeline
|
||||
FROM pages
|
||||
WHERE type = 'meeting'
|
||||
WHERE (type = 'meeting' OR frontmatter ->> 'legacy_type' = 'meeting')
|
||||
AND deleted_at IS NULL
|
||||
${sourceFilter}
|
||||
ORDER BY effective_date DESC NULLS LAST, slug`,
|
||||
@@ -94,7 +97,7 @@ export async function extractTimelineFromMeetings(
|
||||
JOIN pages pf ON pf.id = l.from_page_id
|
||||
JOIN pages pt ON pt.id = l.to_page_id
|
||||
WHERE l.link_type = 'attended'
|
||||
AND pf.type = 'meeting'
|
||||
AND (pf.type = 'meeting' OR pf.frontmatter ->> 'legacy_type' = 'meeting')
|
||||
AND pf.deleted_at IS NULL
|
||||
AND pt.deleted_at IS NULL`,
|
||||
);
|
||||
|
||||
@@ -4562,7 +4562,10 @@ const list_schema_packs: Operation = {
|
||||
const { existsSync, readdirSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const { gbrainPath } = await import('./config.ts');
|
||||
const bundled = ['gbrain-base', 'gbrain-recommended'];
|
||||
// #1726: derive from the locator's registry instead of a hand-copied
|
||||
// subset (which had frozen at 2 of 7 bundled packs).
|
||||
const { BUNDLED_PACKS } = await import('./schema-pack/load-active.ts');
|
||||
const bundled = [...BUNDLED_PACKS];
|
||||
const installedDir = gbrainPath('schema-packs');
|
||||
const installed: string[] = [];
|
||||
if (existsSync(installedDir)) {
|
||||
|
||||
@@ -41,6 +41,12 @@ migration_from:
|
||||
pack: gbrain-base
|
||||
version: "1.x"
|
||||
|
||||
# #2117 — cycle-phase participation. `phases:` is additive and pack-gated;
|
||||
# without this key extract_atoms is silently off on v2 brains even though
|
||||
# onboard + doctor recommend it (v2 declares the `atom` type it writes).
|
||||
phases:
|
||||
- extract_atoms
|
||||
|
||||
page_types:
|
||||
- name: person
|
||||
primitive: entity
|
||||
@@ -319,6 +325,10 @@ page_types:
|
||||
extractable: false
|
||||
expert_routing: false
|
||||
|
||||
# #2117 — inference rules ported from gbrain-base v1 so extract-ner keeps
|
||||
# working on v2 brains (it hard-skips with pack_unavailable when no
|
||||
# link_type declares an inference.regex). Same ReDoS-guarded sketch
|
||||
# regexes v1 ships; production matchers in link-extraction.ts still apply.
|
||||
link_types:
|
||||
- name: partner_of
|
||||
inverse: partner_of
|
||||
@@ -328,14 +338,24 @@ link_types:
|
||||
- name: discusses
|
||||
- name: founded
|
||||
inverse: founded_by
|
||||
inference:
|
||||
regex: \b(founded|founder of|co-?founded|started)\b
|
||||
- name: works_at
|
||||
inverse: employs
|
||||
inference:
|
||||
regex: \b(works? at|employed by|works? for|joined|hired by|ceo of|cto of|cmo of)\b
|
||||
- name: invested_in
|
||||
inverse: investor_of
|
||||
inference:
|
||||
regex: \b(invested in|backed|seeded|funded|wrote a check)\b
|
||||
- name: sourced_from
|
||||
- name: derived_from
|
||||
- name: supersedes
|
||||
- name: redirects_to
|
||||
# NOTE: v1's `attended` inference is page_type-bound to `meeting`, which
|
||||
# v2 does not declare (lint: link_types_undeclared_page_type). Meeting
|
||||
# pages retyped by unify-types are matched via frontmatter.legacy_type
|
||||
# in extract-timeline-from-meetings (#2109) instead.
|
||||
- name: attended
|
||||
inverse: attended_by
|
||||
- name: authored
|
||||
|
||||
@@ -91,29 +91,33 @@ export function _resetPackLocatorForTests(): void {
|
||||
* Returns null when the pack is not found. Callers handle null by
|
||||
* throwing UnknownPackError with a paste-ready install hint.
|
||||
*/
|
||||
// v0.39 T8 — bundled packs registry. gbrain-base + gbrain-recommended
|
||||
// ship in src/core/schema-pack/base/. Add a new entry here to bundle
|
||||
// additional canonical packs.
|
||||
//
|
||||
// v0.41 T4 — lens packs join the bundle: creator (atoms + concepts +
|
||||
// extract_atoms/synthesize_concepts phases), investor (theses + bet
|
||||
// resolution + 3 calibration domains), engineer (gstack-learnings bridge
|
||||
// + 3 calibration domains), everything (meta-pack stacking all three
|
||||
// via extends + borrow_from). Each ships as a real YAML at base/<name>.yaml.
|
||||
//
|
||||
// #1726: exported so reporting surfaces (list_schema_packs) derive from the
|
||||
// same list the locator resolves — no more hand-copied 2-of-7 subsets.
|
||||
export const BUNDLED_PACKS: ReadonlyArray<string> = [
|
||||
'gbrain-base',
|
||||
'gbrain-recommended',
|
||||
'gbrain-creator',
|
||||
'gbrain-investor',
|
||||
'gbrain-engineer',
|
||||
'gbrain-everything',
|
||||
// v0.42 type-unification: 15-type canonical successor to gbrain-base.
|
||||
// Ships as install default (Lane E T17) + via gbrain onboard pack
|
||||
// upgrade flow (the unify-types Minion handler).
|
||||
'gbrain-base-v2',
|
||||
];
|
||||
|
||||
function defaultPackLocator(name: string): string | null {
|
||||
// v0.39 T8 — bundled packs registry. gbrain-base + gbrain-recommended
|
||||
// ship in src/core/schema-pack/base/. Add a new entry here to bundle
|
||||
// additional canonical packs.
|
||||
//
|
||||
// v0.41 T4 — lens packs join the bundle: creator (atoms + concepts +
|
||||
// extract_atoms/synthesize_concepts phases), investor (theses + bet
|
||||
// resolution + 3 calibration domains), engineer (gstack-learnings bridge
|
||||
// + 3 calibration domains), everything (meta-pack stacking all three
|
||||
// via extends + borrow_from). Each ships as a real YAML at base/<name>.yaml.
|
||||
const BUNDLED: ReadonlyArray<string> = [
|
||||
'gbrain-base',
|
||||
'gbrain-recommended',
|
||||
'gbrain-creator',
|
||||
'gbrain-investor',
|
||||
'gbrain-engineer',
|
||||
'gbrain-everything',
|
||||
// v0.42 type-unification: 15-type canonical successor to gbrain-base.
|
||||
// Ships as install default (Lane E T17) + via gbrain onboard pack
|
||||
// upgrade flow (the unify-types Minion handler).
|
||||
'gbrain-base-v2',
|
||||
];
|
||||
if (BUNDLED.includes(name)) {
|
||||
if (BUNDLED_PACKS.includes(name)) {
|
||||
// Resolve bundled YAML relative to this source file. Works in both
|
||||
// direct-bun execution and bun --compile binaries.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -32,7 +32,7 @@ interface CapturedSql {
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
function buildMockEngine(opts: { scorecard: TakesScorecard }): {
|
||||
function buildMockEngine(opts: { scorecard: TakesScorecard; config?: Record<string, string> }): {
|
||||
engine: BrainEngine;
|
||||
captured: CapturedSql[];
|
||||
} {
|
||||
@@ -42,6 +42,9 @@ function buildMockEngine(opts: { scorecard: TakesScorecard }): {
|
||||
async getScorecard() {
|
||||
return opts.scorecard;
|
||||
},
|
||||
async getConfig(key: string) {
|
||||
return opts.config?.[key] ?? null;
|
||||
},
|
||||
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
||||
captured.push({ sql, params: params ?? [] });
|
||||
return [];
|
||||
@@ -241,6 +244,36 @@ describe('runPhaseCalibrationProfile — phase integration', () => {
|
||||
expect(insert!.params[11]).toEqual(['over-confident-geography']); // active_bias_tags
|
||||
});
|
||||
|
||||
test('#1726: calibration.user_holder config drives the holder when no explicit opt', async () => {
|
||||
const { engine, captured } = buildMockEngine({
|
||||
scorecard: ENOUGH_RESOLVED_SCORECARD,
|
||||
config: { 'calibration.user_holder': 'alice-example' },
|
||||
});
|
||||
await runPhaseCalibrationProfile(buildCtx(engine), {
|
||||
patternsGenerator: async () => ['You call early-stage tactics well — 8 of 10 held up.'],
|
||||
biasTagsGenerator: async () => [],
|
||||
voiceGateJudge: passJudge,
|
||||
});
|
||||
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
|
||||
expect(insert).toBeDefined();
|
||||
expect(insert!.params[1]).toBe('alice-example'); // holder from config
|
||||
});
|
||||
|
||||
test('#1726: explicit holder opt wins over calibration.user_holder config', async () => {
|
||||
const { engine, captured } = buildMockEngine({
|
||||
scorecard: ENOUGH_RESOLVED_SCORECARD,
|
||||
config: { 'calibration.user_holder': 'alice-example' },
|
||||
});
|
||||
await runPhaseCalibrationProfile(buildCtx(engine), {
|
||||
holder: 'charlie-example',
|
||||
patternsGenerator: async () => ['You call early-stage tactics well — 8 of 10 held up.'],
|
||||
biasTagsGenerator: async () => [],
|
||||
voiceGateJudge: passJudge,
|
||||
});
|
||||
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
|
||||
expect(insert!.params[1]).toBe('charlie-example');
|
||||
});
|
||||
|
||||
test('default model is a provider-prefixed id, persisted to model_id (#2451)', async () => {
|
||||
const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD });
|
||||
const patternsGenerator: PatternStatementsGenerator = async () => [
|
||||
|
||||
@@ -15,22 +15,19 @@ 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 5 for the estimated-token hard cap', () => {
|
||||
test('bumped to 4 for Cathedral II', () => {
|
||||
// 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.
|
||||
// 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);
|
||||
expect(CHUNKER_VERSION).toBe(4);
|
||||
});
|
||||
|
||||
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(5);
|
||||
expect(a).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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('v5: estimated-token hard cap on AST-path chunks', () => {
|
||||
expect(CHUNKER_VERSION).toBe(5);
|
||||
test('v0.20.0 Cathedral II Layer 12 bumped to 4', () => {
|
||||
expect(CHUNKER_VERSION).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -135,14 +135,13 @@ describe('Recursive Text Chunker', () => {
|
||||
});
|
||||
|
||||
describe('CJK chunking (v0.32.7)', () => {
|
||||
test('MARKDOWN_CHUNKER_VERSION is 4', async () => {
|
||||
test('MARKDOWN_CHUNKER_VERSION is 3', 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.
|
||||
// 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.
|
||||
// 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.
|
||||
const mod = await import('../../src/core/chunkers/recursive.ts');
|
||||
expect(mod.MARKDOWN_CHUNKER_VERSION).toBe(4);
|
||||
expect(mod.MARKDOWN_CHUNKER_VERSION).toBe(3);
|
||||
});
|
||||
|
||||
test('long pure-Chinese paragraph splits into multiple chunks', () => {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// #2109 — gbrain-base-v2's unify-types retypes meeting pages to `note`
|
||||
// with frontmatter.legacy_type='meeting'. extract-timeline-from-meetings
|
||||
// used to hardcode type='meeting' and silently scan 0 meetings on migrated
|
||||
// brains. These tests fail without the legacy_type fallback in both SQL
|
||||
// sites (meeting walk + attended-edge join).
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { extractTimelineFromMeetings } from '../src/core/extract-timeline-from-meetings.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
async function insertPage(opts: {
|
||||
slug: string;
|
||||
type: string;
|
||||
title: string;
|
||||
effectiveDate?: string;
|
||||
legacyType?: string;
|
||||
}): Promise<number> {
|
||||
const frontmatterLiteral = opts.legacyType
|
||||
? `'{"legacy_type": "${opts.legacyType}"}'::jsonb`
|
||||
: `'{}'::jsonb`;
|
||||
const rows = await engine.executeRaw<{ id: number }>(
|
||||
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline, effective_date, frontmatter)
|
||||
VALUES ($1, 'default', $2, $3, '', '', $4, ${frontmatterLiteral})
|
||||
RETURNING id`,
|
||||
[opts.slug, opts.type, opts.title, opts.effectiveDate ?? null],
|
||||
);
|
||||
return rows[0]!.id;
|
||||
}
|
||||
|
||||
describe('extractTimelineFromMeetings — legacy_type fallback (#2109)', () => {
|
||||
it('scans pages retyped to note with legacy_type=meeting and walks their attended edges', async () => {
|
||||
const meetingId = await insertPage({
|
||||
slug: 'meetings/2026-01-05',
|
||||
type: 'note', // post-unify-types shape on a gbrain-base-v2 brain
|
||||
legacyType: 'meeting',
|
||||
title: 'Weekly sync',
|
||||
effectiveDate: '2026-01-05',
|
||||
});
|
||||
const personId = await insertPage({
|
||||
slug: 'people/alice-example',
|
||||
type: 'person',
|
||||
title: 'Alice Example',
|
||||
});
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'attended')`,
|
||||
[meetingId, personId],
|
||||
);
|
||||
|
||||
const result = await extractTimelineFromMeetings(engine);
|
||||
expect(result.meetings_scanned).toBe(1);
|
||||
expect(result.entries_created).toBe(1);
|
||||
expect(result.entities_touched).toBe(1);
|
||||
expect(result.batch_errors).toBe(0);
|
||||
});
|
||||
|
||||
it('still scans pre-unify pages with type=meeting (v1 behavior preserved)', async () => {
|
||||
const meetingId = await insertPage({
|
||||
slug: 'meetings/2026-02-01',
|
||||
type: 'meeting',
|
||||
title: 'Board prep',
|
||||
effectiveDate: '2026-02-01',
|
||||
});
|
||||
const personId = await insertPage({
|
||||
slug: 'people/charlie-example',
|
||||
type: 'person',
|
||||
title: 'Charlie Example',
|
||||
});
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'attended')`,
|
||||
[meetingId, personId],
|
||||
);
|
||||
|
||||
const result = await extractTimelineFromMeetings(engine);
|
||||
expect(result.meetings_scanned).toBe(1);
|
||||
expect(result.entries_created).toBe(1);
|
||||
});
|
||||
|
||||
it('does not scan unrelated note pages without legacy_type=meeting', async () => {
|
||||
await insertPage({
|
||||
slug: 'notes/random',
|
||||
type: 'note',
|
||||
title: 'Random note',
|
||||
effectiveDate: '2026-03-01',
|
||||
});
|
||||
const result = await extractTimelineFromMeetings(engine);
|
||||
expect(result.meetings_scanned).toBe(0);
|
||||
expect(result.entries_created).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,19 @@ describe('list_schema_packs', () => {
|
||||
expect(result.installed).toContain('mine');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the full bundled registry, not a hand-copied subset (#1726)', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpDir }, async () => {
|
||||
const { BUNDLED_PACKS } = await import('../src/core/schema-pack/load-active.ts');
|
||||
const result = await operationsByName.list_schema_packs!.handler(ctxOf(), {}) as { bundled: string[] };
|
||||
expect(result.bundled.slice().sort()).toEqual([...BUNDLED_PACKS].sort());
|
||||
// The lens packs that declare extract_atoms/synthesize_concepts phases
|
||||
// were the ones dropped by the frozen 2-pack literal.
|
||||
for (const name of ['gbrain-creator', 'gbrain-everything', 'gbrain-base-v2']) {
|
||||
expect(result.bundled).toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── schema_stats ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// #2117 — gbrain-base-v2 shipped with no `phases:` declaration and zero
|
||||
// link_types[].inference regexes, so extract_atoms was silently pack-gated
|
||||
// off and extract-ner returned pack_unavailable on the bundled default pack.
|
||||
// These assertions fail against the pre-fix yaml.
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { join } from 'node:path';
|
||||
import { loadPackFromFile } from '../src/core/schema-pack/loader.ts';
|
||||
import { linkTypesUndeclared } from '../src/core/schema-pack/lint-rules.ts';
|
||||
|
||||
const V2_PATH = join(import.meta.dir, '..', 'src', 'core', 'schema-pack', 'base', 'gbrain-base-v2.yaml');
|
||||
|
||||
describe('gbrain-base-v2 capability parity (#2117)', () => {
|
||||
const manifest = loadPackFromFile(V2_PATH);
|
||||
|
||||
it('declares the extract_atoms cycle phase', () => {
|
||||
expect(manifest.phases ?? []).toContain('extract_atoms');
|
||||
});
|
||||
|
||||
it('ships at least one link_type inference regex so extract-ner is not pack_unavailable', () => {
|
||||
// Mirrors the extract-ner hasRegex predicate exactly.
|
||||
const hasRegex = manifest.link_types.some(
|
||||
(lt) => lt.inference && typeof lt.inference === 'object' && 'regex' in lt.inference,
|
||||
);
|
||||
expect(hasRegex).toBe(true);
|
||||
});
|
||||
|
||||
it('ports the v1 inference verbs it declares link types for', () => {
|
||||
const withRegex = manifest.link_types
|
||||
.filter((lt) => lt.inference?.regex)
|
||||
.map((lt) => lt.name)
|
||||
.sort();
|
||||
expect(withRegex).toEqual(['founded', 'invested_in', 'works_at']);
|
||||
});
|
||||
|
||||
it('inference rules pass the undeclared-page-type lint (no meeting-bound inference)', async () => {
|
||||
const issues = await linkTypesUndeclared(manifest);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user