mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
feat(context): multi-turn window extraction + confidence-scored volunteer core (#2095)
- entity-salience.ts: extractCandidatesFromWindow(turns) — runs the existing per-turn extractor across the last N turns (oldest→newest), merges by the normalizeAlias form with occurrence/newest-turn/user-mention metadata, and orders by salience (recency > frequency > user-role) so the MAX_CANDIDATES cap drops stale assistant chatter, not the entity the user just named. Closes the filed assistant-introduced-entities recall TODO; true pronoun coreference (never-named antecedents) stays out of scope. - retrieval-reflex.ts: ReflexPointer gains source_id + arm + confidence + matchedNorm. ARM_CONFIDENCE (alias 0.9 / title 0.8 / slug-suffix 0.6) lives next to the arm definitions so identity and score can't drift. Arm-2 provenance is classified in JS (codex D8 — the combined OR can't report which predicate matched). Federated sourceIds[] scope (alias arm loops per source; arm 2 uses source_id = ANY — no engine-interface change). Suppression gains 'slug-only' mode (codex D7, REQUIRED for windowing): the legacy title-whole-word rule would suppress every entity merely MENTIONED in a prior window turn, breaking the feature by construction — slugs only enter context when a pointer/page was actually surfaced. Default stays 'slug-and-title' for the window=1 legacy path. - volunteer.ts (new): parseWindow (lenient user:/assistant: prefixes, CRLF, unprefixed → one user turn), volunteerContext (zero-LLM: extract → resolve → +0.05 multi-turn/newest-turn boost → min_confidence 0.7 gate → cap 3/5; deterministic rationale strings, never raw conversation text), and volunteerUsageStats (per-arm/channel precision from the last_retrieved_at join, labeled approximate — 5-min throttle false negatives, unrelated-read false positives; codex D9). Tests: 35 green across volunteer-context (window parsing, pronoun follow-up via assistant-introduced entity, confidence gating, slug-only suppression, takes-fence privacy, multi-source scope, caps, stats join math) + retrieval-reflex back-compat + resolve-ipc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
07e4ca4dac
commit
ac1c2ebbc0
@@ -7,12 +7,15 @@
|
||||
* touching the brain, so it must be fast (one regex pass) and precision-biased:
|
||||
* a false candidate costs a wasted resolve and, worse, a misleading pointer.
|
||||
*
|
||||
* DELIBERATE v1 limits (documented, not bugs — see issue #1981 / eng-review):
|
||||
* DELIBERATE limits (documented, not bugs — see issue #1981 / eng-review):
|
||||
* - Proper-case + ASCII biased. Misses lowercase names ("garry") and many
|
||||
* non-Latin scripts.
|
||||
* - Current-user-message only. No pronoun follow-ups ("what about her?"), no
|
||||
* entities the assistant introduced.
|
||||
* These are TODOs, not v1 scope. Do NOT market this as full "human-like recall".
|
||||
* - extractCandidates is single-turn. The v0.43 (#2095) window layer
|
||||
* (extractCandidatesFromWindow) widens extraction across the last N turns
|
||||
* — assistant-introduced entities and "what about her?" follow-ups whose
|
||||
* antecedent was NAMED in the window now resolve; true pronoun
|
||||
* coreference (never-named antecedents) remains out of scope.
|
||||
* Do NOT market this as full "human-like recall".
|
||||
*
|
||||
* Resolution (alias/slug lookup) lives in retrieval-reflex.ts; this module only
|
||||
* decides WHAT to look up.
|
||||
@@ -172,3 +175,83 @@ export function extractCandidates(text: string): EntityCandidate[] {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Rolling-window extraction (v0.43, #2095 push-based context) ──────────
|
||||
|
||||
export interface WindowTurn {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A window candidate with the salience metadata the volunteer layer's
|
||||
* confidence boost reads: how many turns mentioned it, whether the NEWEST
|
||||
* turn did, and whether the USER (vs only the assistant) ever said it.
|
||||
*/
|
||||
export interface WindowEntityCandidate extends EntityCandidate {
|
||||
/** Number of distinct turns that mentioned this candidate. */
|
||||
occurrences: number;
|
||||
/** Mentioned in the newest (last) turn of the window. */
|
||||
inNewestTurn: boolean;
|
||||
/** Mentioned in at least one USER turn (assistant-only mentions rank lower). */
|
||||
userMention: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidates across the last N turns (oldest → newest). Pure,
|
||||
* zero-LLM: runs the per-turn extractor on each turn and merges by the
|
||||
* normalizeAlias form. Ordering is salience-aware — recency of last mention,
|
||||
* cross-turn frequency, and a user-role boost — so when the merged set
|
||||
* exceeds MAX_CANDIDATES, the dropped tail is the stalest assistant-only
|
||||
* chatter, not the entity the user just named.
|
||||
*/
|
||||
export function extractCandidatesFromWindow(turns: WindowTurn[]): WindowEntityCandidate[] {
|
||||
if (!turns?.length) return [];
|
||||
interface WAcc extends WindowEntityCandidate {
|
||||
lastTurnIdx: number;
|
||||
order: number;
|
||||
}
|
||||
const acc = new Map<string, WAcc>();
|
||||
let order = 0;
|
||||
const lastIdx = turns.length - 1;
|
||||
|
||||
for (let i = 0; i < turns.length; i++) {
|
||||
const turn = turns[i];
|
||||
if (!turn?.text) continue;
|
||||
for (const c of extractCandidates(turn.text)) {
|
||||
const norm = normalizeAlias(c.query);
|
||||
if (!norm) continue;
|
||||
const existing = acc.get(norm);
|
||||
if (existing) {
|
||||
existing.occurrences += 1;
|
||||
existing.lastTurnIdx = i;
|
||||
existing.inNewestTurn = existing.inNewestTurn || i === lastIdx;
|
||||
if (turn.role === 'user' && !existing.userMention) {
|
||||
// First USER-said surface form beats an assistant-introduced one
|
||||
// for the display label.
|
||||
existing.display = c.display;
|
||||
existing.userMention = true;
|
||||
}
|
||||
} else {
|
||||
acc.set(norm, {
|
||||
display: c.display,
|
||||
query: c.query,
|
||||
occurrences: 1,
|
||||
lastTurnIdx: i,
|
||||
inNewestTurn: i === lastIdx,
|
||||
userMention: turn.role === 'user',
|
||||
order: order++,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Salience weight: recency dominates, then frequency, then user-role.
|
||||
// Deterministic tie-break on first-seen order.
|
||||
const weight = (c: WAcc) =>
|
||||
(c.lastTurnIdx + 1) / turns.length + Math.min(c.occurrences, 4) * 0.1 + (c.userMention ? 0.15 : 0);
|
||||
return Array.from(acc.values())
|
||||
.sort((a, b) => weight(b) - weight(a) || a.order - b.order)
|
||||
.slice(0, MAX_CANDIDATES)
|
||||
.map(({ lastTurnIdx: _l, order: _o, ...rest }) => rest);
|
||||
}
|
||||
|
||||
@@ -34,10 +34,40 @@ import type { EntityCandidate } from './entity-salience.ts';
|
||||
export const DEFAULT_MAX_POINTERS = 3;
|
||||
const SYNOPSIS_MAX = 160;
|
||||
|
||||
/** Which resolution arm produced a pointer (provenance → honest confidence). */
|
||||
export type ResolveArm = 'alias' | 'title' | 'slug-suffix';
|
||||
|
||||
/**
|
||||
* v0.43 (#2095) — arm → confidence. Lives HERE, next to the arm definitions,
|
||||
* so arm identity and its score can't drift apart (eng-review note). The
|
||||
* volunteer layer imports these; small deterministic boosts (multi-turn /
|
||||
* newest-turn mention) are added on top there.
|
||||
*/
|
||||
export const ARM_CONFIDENCE: Record<ResolveArm, number> = {
|
||||
alias: 0.9,
|
||||
title: 0.8,
|
||||
'slug-suffix': 0.6,
|
||||
};
|
||||
|
||||
export interface ReflexPointer {
|
||||
display: string;
|
||||
slug: string;
|
||||
/** Which brain source the page lives in (federated callers need it for dedup). */
|
||||
source_id: string;
|
||||
synopsis: string;
|
||||
/** Resolution provenance (v0.43 #2095). */
|
||||
arm: ResolveArm;
|
||||
/** Base arm confidence (ARM_CONFIDENCE[arm]); callers may boost. */
|
||||
confidence: number;
|
||||
/**
|
||||
* normalizeAlias form of the CANDIDATE that resolved this pointer (v0.43
|
||||
* #2095) — lets the volunteer layer join pointers back to window-salience
|
||||
* metadata without guessing from the display label (which falls back to the
|
||||
* page title when the candidate surface differs, e.g. alias "Swami" →
|
||||
* title "Swami X"). Absent only when suffix classification couldn't
|
||||
* recover the source candidate.
|
||||
*/
|
||||
matchedNorm?: string;
|
||||
}
|
||||
|
||||
export interface PointerBlock {
|
||||
@@ -55,10 +85,30 @@ export interface ResolvePointersOpts {
|
||||
* message's own mention would suppress every pointer (eng-review/Codex fix).
|
||||
*/
|
||||
priorContextText?: string;
|
||||
/**
|
||||
* v0.43 (#2095, codex D7) — suppression mode.
|
||||
* 'slug-and-title' (default, window=1 legacy): suppress when the slug OR
|
||||
* the page title appears whole-word in prior context.
|
||||
* 'slug-only' (REQUIRED under multi-turn windowing): suppress on slug
|
||||
* presence only. Slugs only enter context when a pointer block or page
|
||||
* body was actually injected; a bare mention of "Alice Example" in a
|
||||
* prior turn never contains `people/alice-example`. The title rule
|
||||
* would suppress every entity merely MENTIONED in a prior window turn
|
||||
* — breaking window extraction by construction.
|
||||
*/
|
||||
suppression?: 'slug-and-title' | 'slug-only';
|
||||
/**
|
||||
* v0.43 (#2095) — federated scope: resolve across these sources instead of
|
||||
* the single positional sourceId. Precedence mirrors sourceScopeOpts
|
||||
* (federated array > scalar). Alias arm loops per source; the title/slug
|
||||
* arm uses source_id = ANY(...) in one query.
|
||||
*/
|
||||
sourceIds?: string[];
|
||||
}
|
||||
|
||||
interface PageRow {
|
||||
slug: string;
|
||||
source_id: string;
|
||||
title: string;
|
||||
type: string | null;
|
||||
frontmatter: Record<string, unknown> | null;
|
||||
@@ -102,26 +152,49 @@ export async function resolveEntitiesToPointers(
|
||||
}
|
||||
if (!aliasNorms.length) return null;
|
||||
|
||||
// Ordered set of resolved slugs (alias hits first → higher confidence).
|
||||
const resolvedSlugs: string[] = [];
|
||||
// Federated scope (v0.43 #2095): explicit sourceIds win over the scalar.
|
||||
const sourceIds = opts.sourceIds?.length ? opts.sourceIds : [sourceId];
|
||||
|
||||
// Ordered set of resolved (source, slug) pairs with arm provenance —
|
||||
// alias hits pushed first → higher confidence.
|
||||
const resolved: Array<{ slug: string; source_id: string; arm: ResolveArm; matchedNorm?: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const pushSlug = (slug: string) => {
|
||||
if (slug && !seen.has(slug)) {
|
||||
seen.add(slug);
|
||||
resolvedSlugs.push(slug);
|
||||
// Neither source ids nor slugs contain spaces, so a space separator is safe.
|
||||
const keyOf = (src: string, slug: string) => `${src} ${slug}`;
|
||||
const push = (slug: string, src: string, arm: ResolveArm, matchedNorm?: string) => {
|
||||
if (!slug) return;
|
||||
const k = keyOf(src, slug);
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
resolved.push({ slug, source_id: src, arm, matchedNorm });
|
||||
}
|
||||
};
|
||||
// Reverse maps for arm-2 provenance: which CANDIDATE produced this row
|
||||
// (titles/slugs were built per candidate above, in the same pass).
|
||||
const titleToNorm = new Map<string, string>();
|
||||
const slugToNorm = new Map<string, string>();
|
||||
for (const c of candidates) {
|
||||
const norm = normalizeAlias(c.query);
|
||||
if (!norm) continue;
|
||||
const tl = c.query.toLowerCase();
|
||||
if (!titleToNorm.has(tl)) titleToNorm.set(tl, norm);
|
||||
const s = slugify(c.query);
|
||||
if (s && !slugToNorm.has(s)) slugToNorm.set(s, norm);
|
||||
}
|
||||
|
||||
// Arm 1 — alias-first. Unambiguous single-slug hits only. Guarded: pre-v110
|
||||
// brains throw "relation page_aliases does not exist" — swallow and continue.
|
||||
try {
|
||||
const aliasMap = await engine.resolveAliases(aliasNorms, { sourceId });
|
||||
for (const norm of aliasNorms) {
|
||||
const hits = aliasMap.get(norm);
|
||||
if (hits && hits.length === 1) pushSlug(hits[0].slug);
|
||||
// Arm 1 — alias-first. Unambiguous single-slug hits only, per source (no
|
||||
// engine-interface change for federation). Guarded: pre-v110 brains throw
|
||||
// "relation page_aliases does not exist" — swallow and continue.
|
||||
for (const src of sourceIds) {
|
||||
try {
|
||||
const aliasMap = await engine.resolveAliases(aliasNorms, { sourceId: src });
|
||||
for (const norm of aliasNorms) {
|
||||
const hits = aliasMap.get(norm);
|
||||
if (hits && hits.length === 1) push(hits[0].slug, src, 'alias', norm);
|
||||
}
|
||||
} catch {
|
||||
/* no page_aliases table (pre-v110) — degrade to the title/slug arm */
|
||||
}
|
||||
} catch {
|
||||
/* no page_aliases table (pre-v110) — degrade to the title/slug arm */
|
||||
}
|
||||
|
||||
// Arm 2 — exact title OR slug-suffix. This is the recall fix: a bare "Alice
|
||||
@@ -130,53 +203,72 @@ export async function resolveEntitiesToPointers(
|
||||
let rows: PageRow[] = [];
|
||||
try {
|
||||
rows = await engine.executeRaw<PageRow>(
|
||||
`SELECT slug, title, type, frontmatter, compiled_truth
|
||||
`SELECT slug, source_id, title, type, frontmatter, compiled_truth
|
||||
FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
AND source_id = $1
|
||||
AND source_id = ANY($1::text[])
|
||||
AND ( lower(title) = ANY($2::text[])
|
||||
OR slug = ANY($3::text[])
|
||||
OR slug LIKE ANY($4::text[]) )`,
|
||||
[sourceId, titlesLc, exactSlugs, slugSuffixes],
|
||||
[sourceIds, titlesLc, exactSlugs, slugSuffixes],
|
||||
);
|
||||
} catch {
|
||||
rows = [];
|
||||
}
|
||||
// Hydrate alias-resolved slugs too (their bodies for the synopsis) if not in rows.
|
||||
const rowBySlug = new Map<string, PageRow>();
|
||||
for (const r of rows) rowBySlug.set(r.slug, r);
|
||||
const aliasOnly = resolvedSlugs.filter((s) => !rowBySlug.has(s));
|
||||
// Hydrate alias-resolved pages too (their bodies for the synopsis) if not in rows.
|
||||
const rowByKey = new Map<string, PageRow>();
|
||||
for (const r of rows) rowByKey.set(keyOf(r.source_id, r.slug), r);
|
||||
const aliasOnly = resolved.filter((p) => !rowByKey.has(keyOf(p.source_id, p.slug)));
|
||||
if (aliasOnly.length) {
|
||||
try {
|
||||
const extra = await engine.executeRaw<PageRow>(
|
||||
`SELECT slug, title, type, frontmatter, compiled_truth
|
||||
`SELECT slug, source_id, title, type, frontmatter, compiled_truth
|
||||
FROM pages
|
||||
WHERE deleted_at IS NULL AND source_id = $1 AND slug = ANY($2::text[])`,
|
||||
[sourceId, aliasOnly],
|
||||
WHERE deleted_at IS NULL AND source_id = ANY($1::text[]) AND slug = ANY($2::text[])`,
|
||||
[sourceIds, aliasOnly.map((p) => p.slug)],
|
||||
);
|
||||
for (const r of extra) rowBySlug.set(r.slug, r);
|
||||
for (const r of extra) rowByKey.set(keyOf(r.source_id, r.slug), r);
|
||||
} catch {
|
||||
/* ignore — alias slug may be stale */
|
||||
}
|
||||
}
|
||||
// Title/slug matches that weren't alias hits, appended after alias hits.
|
||||
for (const r of rows) pushSlug(r.slug);
|
||||
// Arm provenance per row is classified in JS (codex D8) — the combined OR
|
||||
// can't report which predicate matched: an exact lower(title) hit is the
|
||||
// 'title' arm; anything else got in via slug / slug-suffix.
|
||||
const titleSet = new Set(titlesLc);
|
||||
for (const r of rows) {
|
||||
const titleLc = (r.title ?? '').toLowerCase();
|
||||
if (titleSet.has(titleLc)) {
|
||||
push(r.slug, r.source_id, 'title', titleToNorm.get(titleLc));
|
||||
} else {
|
||||
// Slug arm: exact slugified-candidate match, else suffix scan.
|
||||
const tail = r.slug.includes('/') ? r.slug.slice(r.slug.lastIndexOf('/') + 1) : r.slug;
|
||||
push(r.slug, r.source_id, 'slug-suffix', slugToNorm.get(r.slug) ?? slugToNorm.get(tail));
|
||||
}
|
||||
}
|
||||
|
||||
// Build pointers in confidence order, applying suppression + cap.
|
||||
const suppression = opts.suppression ?? 'slug-and-title';
|
||||
const pointers: ReflexPointer[] = [];
|
||||
for (const slug of resolvedSlugs) {
|
||||
const row = rowBySlug.get(slug);
|
||||
for (const { slug, source_id, arm, matchedNorm } of resolved) {
|
||||
const row = rowByKey.get(keyOf(source_id, slug));
|
||||
if (!row) continue;
|
||||
// Suppression: already present in PRIOR context (slug or title). The current
|
||||
// turn is deliberately excluded from priorContextText.
|
||||
// Suppression: already present in PRIOR context. The current turn is
|
||||
// deliberately excluded from priorContextText. Under windowing
|
||||
// ('slug-only', codex D7) only the slug counts — a slug appears in prior
|
||||
// context only when a pointer/page was actually surfaced there, while a
|
||||
// title appears on any bare mention.
|
||||
if (priorLc) {
|
||||
const titleLc = (row.title ?? '').toLowerCase();
|
||||
if (priorLc.includes(slug.toLowerCase())) continue;
|
||||
if (titleLc && wholeWordIncludes(priorLc, titleLc)) continue;
|
||||
if (suppression === 'slug-and-title') {
|
||||
const titleLc = (row.title ?? '').toLowerCase();
|
||||
if (titleLc && wholeWordIncludes(priorLc, titleLc)) continue;
|
||||
}
|
||||
}
|
||||
const display = displayForRow(row, displayByNorm);
|
||||
const synopsis = safeSynopsis(row);
|
||||
pointers.push({ display, slug, synopsis });
|
||||
pointers.push({ display, slug, source_id, synopsis, arm, confidence: ARM_CONFIDENCE[arm], matchedNorm });
|
||||
if (pointers.length >= maxPointers) break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* v0.43 (#2095) — push-based context: the brain VOLUNTEERS relevant pages
|
||||
* from a rolling conversation window instead of waiting to be asked.
|
||||
*
|
||||
* window text ─ parseWindow() ─→ turns[] ─ extractCandidatesFromWindow()
|
||||
* │ (recency + frequency +
|
||||
* │ user-role weights)
|
||||
* ▼
|
||||
* resolveEntitiesToPointers(sourceIds[]) alias 0.9 / title 0.8 /
|
||||
* │ slug-suffix 0.6 (+0.05 boost
|
||||
* ▼ for ≥2-turn or newest-turn
|
||||
* gate min_confidence (default 0.7) → mentions)
|
||||
* suppression (slug-only — windowing) →
|
||||
* cap (3 default / 5 max)
|
||||
*
|
||||
* Zero-LLM, deterministic, precision-biased: push noise is worse than pull
|
||||
* silence (#2095). At the default gate, slug-suffix matches (0.6+0.05 < 0.7)
|
||||
* never volunteer — they need an explicit lower min_confidence.
|
||||
*
|
||||
* Consumed by three channels: the volunteer_context op, the retrieval-reflex
|
||||
* window path, and `gbrain watch`. Event logging lives in
|
||||
* volunteer-events.ts; usage stats here derive "used" from
|
||||
* pages.last_retrieved_at — APPROXIMATE by design (the 5-min last-retrieved
|
||||
* throttle causes false negatives; unrelated reads cause false positives).
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { normalizeAlias } from '../search/alias-normalize.ts';
|
||||
import {
|
||||
extractCandidatesFromWindow,
|
||||
type WindowTurn,
|
||||
type WindowEntityCandidate,
|
||||
} from './entity-salience.ts';
|
||||
import {
|
||||
resolveEntitiesToPointers,
|
||||
ARM_CONFIDENCE,
|
||||
type ResolveArm,
|
||||
} from './retrieval-reflex.ts';
|
||||
|
||||
export const VOLUNTEER_DEFAULT_MAX_PAGES = 3;
|
||||
export const VOLUNTEER_MAX_PAGES_CAP = 5;
|
||||
export const VOLUNTEER_DEFAULT_MIN_CONFIDENCE = 0.7;
|
||||
/** Deterministic boost for ≥2-turn or newest-turn mentions. */
|
||||
export const VOLUNTEER_SALIENCE_BOOST = 0.05;
|
||||
|
||||
export interface VolunteeredPage {
|
||||
slug: string;
|
||||
source_id: string;
|
||||
display: string;
|
||||
confidence: number;
|
||||
arm: ResolveArm;
|
||||
/** Deterministic template string — never raw conversation text. */
|
||||
rationale: string;
|
||||
synopsis: string;
|
||||
}
|
||||
|
||||
export interface VolunteerOpts {
|
||||
/** Resolved source scope (federated array > scalar — sourceScopeOpts shape). */
|
||||
sourceIds: string[];
|
||||
/** Prior context (already-surfaced pointers/pages) for slug-only suppression. */
|
||||
priorContext?: string;
|
||||
maxPages?: number;
|
||||
minConfidence?: number;
|
||||
}
|
||||
|
||||
const TURN_PREFIX_RE = /^(user|assistant)\s*:\s?(.*)$/i;
|
||||
|
||||
/**
|
||||
* Lenient window parser: `user:` / `assistant:` line prefixes start a new
|
||||
* turn (oldest → newest); unprefixed lines continue the current turn; input
|
||||
* with no prefixes at all is ONE user turn (so `echo "..." | volunteer-context`
|
||||
* just works). CRLF-tolerant. Empty/whitespace input → [].
|
||||
*/
|
||||
export function parseWindow(text: string): WindowTurn[] {
|
||||
if (!text || !text.trim()) return [];
|
||||
const turns: WindowTurn[] = [];
|
||||
let current: WindowTurn | null = null;
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const m = TURN_PREFIX_RE.exec(rawLine);
|
||||
if (m) {
|
||||
if (current) turns.push(current);
|
||||
current = { role: m[1].toLowerCase() as WindowTurn['role'], text: m[2] ?? '' };
|
||||
} else if (current) {
|
||||
current.text += (current.text ? '\n' : '') + rawLine;
|
||||
} else if (rawLine.trim()) {
|
||||
// No prefix seen yet — accumulate into an implicit user turn.
|
||||
current = { role: 'user', text: rawLine };
|
||||
}
|
||||
}
|
||||
if (current) turns.push(current);
|
||||
// Trim trailing whitespace-only turn bodies.
|
||||
return turns
|
||||
.map((t) => ({ role: t.role, text: t.text.trim() }))
|
||||
.filter((t) => t.text.length > 0);
|
||||
}
|
||||
|
||||
function clampMaxPages(n: number | undefined): number {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n) || n < 1) return VOLUNTEER_DEFAULT_MAX_PAGES;
|
||||
return Math.min(Math.floor(n), VOLUNTEER_MAX_PAGES_CAP);
|
||||
}
|
||||
|
||||
function rationaleFor(arm: ResolveArm, display: string, c: WindowEntityCandidate | undefined, windowSize: number): string {
|
||||
const armText =
|
||||
arm === 'alias' ? `alias match "${display}"`
|
||||
: arm === 'title' ? `exact title match "${display}"`
|
||||
: `slug match "${display}"`;
|
||||
if (!c) return armText;
|
||||
const parts = [armText];
|
||||
if (c.occurrences >= 2) parts.push(`mentioned in ${c.occurrences} of last ${windowSize} turns`);
|
||||
else if (c.inNewestTurn) parts.push('mentioned in the newest turn');
|
||||
if (!c.userMention) parts.push('assistant-introduced');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Volunteer confidence-gated pages for a conversation window. Pure read —
|
||||
* event logging is the CALLER's job (through the volunteer-events sink).
|
||||
* Non-relational, zero-LLM; returns [] when nothing clears the gate.
|
||||
*/
|
||||
export async function volunteerContext(
|
||||
engine: BrainEngine,
|
||||
turns: WindowTurn[],
|
||||
opts: VolunteerOpts,
|
||||
): Promise<VolunteeredPage[]> {
|
||||
if (!turns.length || !opts.sourceIds?.length) return [];
|
||||
const candidates = extractCandidatesFromWindow(turns);
|
||||
if (!candidates.length) return [];
|
||||
const byNorm = new Map<string, WindowEntityCandidate>();
|
||||
for (const c of candidates) {
|
||||
const norm = normalizeAlias(c.query);
|
||||
if (norm && !byNorm.has(norm)) byNorm.set(norm, c);
|
||||
}
|
||||
|
||||
const maxPages = clampMaxPages(opts.maxPages);
|
||||
const minConfidence =
|
||||
typeof opts.minConfidence === 'number' && opts.minConfidence >= 0 && opts.minConfidence <= 1
|
||||
? opts.minConfidence
|
||||
: VOLUNTEER_DEFAULT_MIN_CONFIDENCE;
|
||||
|
||||
// Resolve up to the hard cap so the confidence gate sees the full pool —
|
||||
// a gated-out alias hit must not shadow a passing title hit behind it.
|
||||
const block = await resolveEntitiesToPointers(engine, opts.sourceIds[0], candidates, {
|
||||
sourceIds: opts.sourceIds,
|
||||
priorContextText: opts.priorContext,
|
||||
suppression: 'slug-only',
|
||||
maxPointers: VOLUNTEER_MAX_PAGES_CAP * 2,
|
||||
});
|
||||
if (!block) return [];
|
||||
|
||||
const out: VolunteeredPage[] = [];
|
||||
for (const p of block.pointers) {
|
||||
// matchedNorm is the resolver's provenance join-key (the candidate that
|
||||
// resolved the pointer); display-based lookup is the fallback for the
|
||||
// rare suffix rows where provenance couldn't be recovered.
|
||||
const cand = (p.matchedNorm ? byNorm.get(p.matchedNorm) : undefined) ?? byNorm.get(normalizeAlias(p.display));
|
||||
const boost = cand && (cand.occurrences >= 2 || cand.inNewestTurn) ? VOLUNTEER_SALIENCE_BOOST : 0;
|
||||
const confidence = Math.min(0.99, ARM_CONFIDENCE[p.arm] + boost);
|
||||
if (confidence < minConfidence) continue;
|
||||
out.push({
|
||||
slug: p.slug,
|
||||
source_id: p.source_id,
|
||||
display: p.display,
|
||||
confidence,
|
||||
arm: p.arm,
|
||||
rationale: rationaleFor(p.arm, p.display, cand, turns.length),
|
||||
synopsis: p.synopsis,
|
||||
});
|
||||
if (out.length >= maxPages) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Usage stats (the feedback loop) ──────────────────────────────────────
|
||||
|
||||
export interface VolunteerArmStats {
|
||||
match_arm: string;
|
||||
channel: string;
|
||||
volunteered: number;
|
||||
used: number;
|
||||
/** used / volunteered, 0 when nothing volunteered. */
|
||||
precision: number;
|
||||
}
|
||||
|
||||
export interface VolunteerUsageStats {
|
||||
days: number;
|
||||
/** The join is approximate — see the note (false +/- documented in #2095 D9). */
|
||||
approximate: true;
|
||||
note: string;
|
||||
total_volunteered: number;
|
||||
total_used: number;
|
||||
by_arm: VolunteerArmStats[];
|
||||
}
|
||||
|
||||
export const VOLUNTEER_STATS_NOTE =
|
||||
'approximate: "used" = pages.last_retrieved_at > volunteered_at. The 5-min ' +
|
||||
'last-retrieved throttle causes false negatives; unrelated reads of the same ' +
|
||||
'page cause false positives.';
|
||||
|
||||
/**
|
||||
* Per-arm/channel precision over the last N days, source-scoped. Read-only;
|
||||
* returns zeroed stats on pre-v116 brains (no table).
|
||||
*/
|
||||
export async function volunteerUsageStats(
|
||||
engine: BrainEngine,
|
||||
sourceIds: string[],
|
||||
days = 30,
|
||||
): Promise<VolunteerUsageStats> {
|
||||
const safeDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 30;
|
||||
let rows: Array<{ match_arm: string; channel: string; volunteered: string | number; used: string | number }> = [];
|
||||
try {
|
||||
rows = await engine.executeRaw(
|
||||
`SELECT e.match_arm, e.channel,
|
||||
count(*)::text AS volunteered,
|
||||
count(*) FILTER (WHERE p.last_retrieved_at > e.volunteered_at)::text AS used
|
||||
FROM context_volunteer_events e
|
||||
LEFT JOIN pages p
|
||||
ON p.source_id = e.source_id AND p.slug = e.slug AND p.deleted_at IS NULL
|
||||
WHERE e.source_id = ANY($1::text[])
|
||||
AND e.volunteered_at > now() - ($2 || ' days')::interval
|
||||
GROUP BY e.match_arm, e.channel
|
||||
ORDER BY e.match_arm, e.channel`,
|
||||
[sourceIds, String(safeDays)],
|
||||
);
|
||||
} catch {
|
||||
rows = []; // pre-v116 brain — table doesn't exist yet
|
||||
}
|
||||
const by_arm: VolunteerArmStats[] = rows.map((r) => {
|
||||
const volunteered = Number(r.volunteered);
|
||||
const used = Number(r.used);
|
||||
return {
|
||||
match_arm: r.match_arm,
|
||||
channel: r.channel,
|
||||
volunteered,
|
||||
used,
|
||||
precision: volunteered > 0 ? Number((used / volunteered).toFixed(3)) : 0,
|
||||
};
|
||||
});
|
||||
return {
|
||||
days: safeDays,
|
||||
approximate: true,
|
||||
note: VOLUNTEER_STATS_NOTE,
|
||||
total_volunteered: by_arm.reduce((s, a) => s + a.volunteered, 0),
|
||||
total_used: by_arm.reduce((s, a) => s + a.used, 0),
|
||||
by_arm,
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,10 @@ describe('resolve IPC', () => {
|
||||
test('round-trip: client gets the pointer block the server returns', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const block: PointerBlock = { pointers: [{ display: 'Alice', slug: 'people/alice', synopsis: 'x' }], text: 'BLOCK' };
|
||||
const block: PointerBlock = {
|
||||
pointers: [{ display: 'Alice', slug: 'people/alice', source_id: 'default', synopsis: 'x', arm: 'alias', confidence: 0.9 }],
|
||||
text: 'BLOCK',
|
||||
};
|
||||
const server = await startResolveIpcServer(sock, async (req) => {
|
||||
expect(req.candidates[0].query).toBe('Alice');
|
||||
return block;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* v0.43 (#2095) — push-based context core: window parsing, multi-turn
|
||||
* extraction, confidence-gated volunteering, slug-only suppression, privacy,
|
||||
* and the usage-stats join. Hermetic in-memory PGLite (no file lock),
|
||||
* modeled on test/retrieval-reflex.test.ts.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { normalizeAlias } from '../src/core/search/alias-normalize.ts';
|
||||
import {
|
||||
extractCandidatesFromWindow,
|
||||
MAX_CANDIDATES,
|
||||
type WindowTurn,
|
||||
} from '../src/core/context/entity-salience.ts';
|
||||
import { resolveEntitiesToPointers } from '../src/core/context/retrieval-reflex.ts';
|
||||
import {
|
||||
parseWindow,
|
||||
volunteerContext,
|
||||
volunteerUsageStats,
|
||||
VOLUNTEER_DEFAULT_MIN_CONFIDENCE,
|
||||
} from '../src/core/context/volunteer.ts';
|
||||
import { insertVolunteerEvents } from '../src/core/context/volunteer-events.ts';
|
||||
import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
async function seed(slug: string, title: string, body: string, source = 'default') {
|
||||
if (source !== 'default') {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path) VALUES ($1, $1, $2)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[source, `/tmp/${source}`],
|
||||
);
|
||||
}
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
|
||||
VALUES ($1, $2, 'person', $3, $4, '')`,
|
||||
[slug, source, title, body],
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await engine.executeRaw('DELETE FROM page_aliases').catch(() => {});
|
||||
await engine.executeRaw('DELETE FROM context_volunteer_events').catch(() => {});
|
||||
await engine.executeRaw('DELETE FROM pages');
|
||||
});
|
||||
|
||||
describe('parseWindow', () => {
|
||||
test('role prefixes split turns oldest → newest', () => {
|
||||
const turns = parseWindow('user: ask Alice about the deal\nassistant: noted\nuser: what did she say?');
|
||||
expect(turns).toEqual([
|
||||
{ role: 'user', text: 'ask Alice about the deal' },
|
||||
{ role: 'assistant', text: 'noted' },
|
||||
{ role: 'user', text: 'what did she say?' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('CRLF input parses identically', () => {
|
||||
const turns = parseWindow('user: hello\r\nassistant: hi\r\n');
|
||||
expect(turns).toEqual([
|
||||
{ role: 'user', text: 'hello' },
|
||||
{ role: 'assistant', text: 'hi' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('unprefixed text is ONE user turn (echo | volunteer-context just works)', () => {
|
||||
const turns = parseWindow('met with Alice Example today\nshe asked about acme');
|
||||
expect(turns).toEqual([{ role: 'user', text: 'met with Alice Example today\nshe asked about acme' }]);
|
||||
});
|
||||
|
||||
test('continuation lines append to the open turn', () => {
|
||||
const turns = parseWindow('user: first line\nsecond line\nassistant: ok');
|
||||
expect(turns[0]).toEqual({ role: 'user', text: 'first line\nsecond line' });
|
||||
expect(turns[1]).toEqual({ role: 'assistant', text: 'ok' });
|
||||
});
|
||||
|
||||
test('empty / whitespace input → []', () => {
|
||||
expect(parseWindow('')).toEqual([]);
|
||||
expect(parseWindow(' \n \n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCandidatesFromWindow', () => {
|
||||
test('merges across turns with occurrence + newest-turn metadata', () => {
|
||||
const turns: WindowTurn[] = [
|
||||
{ role: 'user', text: 'ask Alice Example about the deal' },
|
||||
{ role: 'assistant', text: 'Alice Example said she will follow up' },
|
||||
{ role: 'user', text: 'and ping Bob Sample too' },
|
||||
];
|
||||
const cands = extractCandidatesFromWindow(turns);
|
||||
const alice = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Alice Example'));
|
||||
const bob = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Bob Sample'));
|
||||
expect(alice).toBeDefined();
|
||||
expect(alice!.occurrences).toBe(2);
|
||||
expect(alice!.inNewestTurn).toBe(false);
|
||||
expect(alice!.userMention).toBe(true);
|
||||
expect(bob).toBeDefined();
|
||||
expect(bob!.inNewestTurn).toBe(true);
|
||||
});
|
||||
|
||||
test('assistant-only mention is flagged (userMention=false)', () => {
|
||||
const cands = extractCandidatesFromWindow([
|
||||
{ role: 'assistant', text: 'You should talk to Charlie Demo about this' },
|
||||
{ role: 'user', text: 'good idea' },
|
||||
]);
|
||||
const charlie = cands.find((c) => normalizeAlias(c.query) === normalizeAlias('Charlie Demo'));
|
||||
expect(charlie).toBeDefined();
|
||||
expect(charlie!.userMention).toBe(false);
|
||||
});
|
||||
|
||||
test('cap holds across a noisy window', () => {
|
||||
const noisy = Array.from({ length: 30 }, (_, i) => `Entity Number${i} did something.`).join(' ');
|
||||
const cands = extractCandidatesFromWindow([{ role: 'user', text: noisy }]);
|
||||
expect(cands.length).toBeLessThanOrEqual(MAX_CANDIDATES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('volunteerContext', () => {
|
||||
test('assistant-introduced entity two turns back resolves on a pronoun follow-up', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'Alice is an early founder.');
|
||||
const turns = parseWindow(
|
||||
'user: who should I talk to about the seed round?\n' +
|
||||
'assistant: Alice Example led a similar round last year.\n' +
|
||||
'user: what did she invest in?',
|
||||
);
|
||||
const pages = await volunteerContext(engine, turns, { sourceIds: ['default'] });
|
||||
expect(pages.length).toBe(1);
|
||||
expect(pages[0].slug).toBe('people/alice-example');
|
||||
expect(pages[0].arm).toBe('title');
|
||||
expect(pages[0].rationale).toContain('assistant-introduced');
|
||||
});
|
||||
|
||||
test('confidence gate drops slug-suffix matches at the default threshold', async () => {
|
||||
// Page resolvable ONLY via slug-suffix: title differs from the mention.
|
||||
await seed('projects/widget-co', 'The Widget Company Project', 'A project page.');
|
||||
const turns = parseWindow('user: any updates on Widget-Co this week?');
|
||||
const gated = await volunteerContext(engine, turns, { sourceIds: ['default'] });
|
||||
expect(gated).toEqual([]);
|
||||
// Lowering min_confidence lets it through with honest provenance.
|
||||
const loose = await volunteerContext(engine, turns, { sourceIds: ['default'], minConfidence: 0.5 });
|
||||
expect(loose.length).toBe(1);
|
||||
expect(loose[0].arm).toBe('slug-suffix');
|
||||
expect(loose[0].confidence).toBeLessThan(VOLUNTEER_DEFAULT_MIN_CONFIDENCE);
|
||||
});
|
||||
|
||||
test('alias arm volunteers with boost when mentioned in the newest turn', async () => {
|
||||
await seed('people/swami-x', 'Swami X', 'A close friend.');
|
||||
await engine.setPageAliases('people/swami-x', 'default', [normalizeAlias('Swami')]);
|
||||
const pages = await volunteerContext(
|
||||
engine,
|
||||
parseWindow('user: Spoke with Swami today'),
|
||||
{ sourceIds: ['default'] },
|
||||
);
|
||||
expect(pages.length).toBe(1);
|
||||
expect(pages[0].arm).toBe('alias');
|
||||
expect(pages[0].confidence).toBeCloseTo(0.95, 5); // 0.9 + newest-turn boost
|
||||
expect(pages[0].rationale).toContain('alias match');
|
||||
});
|
||||
|
||||
test('suppression is slug-only under windowing: a prior-turn MENTION does not suppress', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'A founder.');
|
||||
// Prior context contains the TITLE (a bare mention from an earlier turn)
|
||||
// but NOT the slug — the page was never actually surfaced.
|
||||
const pages = await volunteerContext(
|
||||
engine,
|
||||
parseWindow('user: ping Alice Example again'),
|
||||
{ sourceIds: ['default'], priorContext: 'earlier the user said: tell Alice Example the news' },
|
||||
);
|
||||
expect(pages.length).toBe(1);
|
||||
// A slug in prior context (the page WAS surfaced) does suppress.
|
||||
const suppressed = await volunteerContext(
|
||||
engine,
|
||||
parseWindow('user: ping Alice Example again'),
|
||||
{ sourceIds: ['default'], priorContext: 'pointer: people/alice-example was injected last turn' },
|
||||
);
|
||||
expect(suppressed).toEqual([]);
|
||||
});
|
||||
|
||||
test('privacy: takes-fence content never leaks into the synopsis', async () => {
|
||||
const body = `${TAKES_FENCE_BEGIN}\nSECRET_HUNCH_DO_NOT_LEAK\n${TAKES_FENCE_END}\nAlice is a founder.`;
|
||||
await seed('people/alice-example', 'Alice Example', body);
|
||||
const pages = await volunteerContext(engine, parseWindow('user: about Alice Example'), { sourceIds: ['default'] });
|
||||
expect(pages.length).toBe(1);
|
||||
expect(JSON.stringify(pages)).not.toContain('SECRET_HUNCH_DO_NOT_LEAK');
|
||||
});
|
||||
|
||||
test('multi-source scope: resolves from both sources, never outside the scope', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'Founder.', 'default');
|
||||
await seed('people/bob-sample', 'Bob Sample', 'Engineer.', 'team-brain');
|
||||
await seed('people/eve-other', 'Eve Other', 'Out of scope.', 'private-brain');
|
||||
const turns = parseWindow('user: intro Alice Example to Bob Sample and Eve Other');
|
||||
const pages = await volunteerContext(engine, turns, { sourceIds: ['default', 'team-brain'] });
|
||||
const keys = pages.map((p) => `${p.source_id}:${p.slug}`).sort();
|
||||
expect(keys).toEqual(['default:people/alice-example', 'team-brain:people/bob-sample']);
|
||||
});
|
||||
|
||||
test('zero-candidate fast path: no entities → [] without touching resolution', async () => {
|
||||
const pages = await volunteerContext(engine, parseWindow('user: ok thanks, sounds good'), {
|
||||
sourceIds: ['default'],
|
||||
});
|
||||
expect(pages).toEqual([]);
|
||||
});
|
||||
|
||||
test('max_pages caps at 5 even when asked for more', async () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await seed(`people/person-${i}`, `Person Alpha${i}`, 'A person.');
|
||||
}
|
||||
const text = Array.from({ length: 8 }, (_, i) => `Person Alpha${i}`).join(' and ');
|
||||
const pages = await volunteerContext(engine, parseWindow(`user: intro ${text}`), {
|
||||
sourceIds: ['default'],
|
||||
maxPages: 50,
|
||||
});
|
||||
expect(pages.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('volunteerUsageStats', () => {
|
||||
test('join math: used = last_retrieved_at > volunteered_at, labeled approximate', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
||||
await seed('people/bob-sample', 'Bob Sample', 'Engineer.');
|
||||
await insertVolunteerEvents(engine, [
|
||||
{ source_id: 'default', slug: 'people/alice-example', confidence: 0.9, match_arm: 'alias', rationale: 'r', channel: 'op' },
|
||||
{ source_id: 'default', slug: 'people/bob-sample', confidence: 0.8, match_arm: 'title', rationale: 'r', channel: 'op' },
|
||||
]);
|
||||
// Alice was opened AFTER being volunteered; Bob never was.
|
||||
await engine.executeRaw(
|
||||
`UPDATE pages SET last_retrieved_at = now() + interval '1 minute' WHERE slug = 'people/alice-example'`, [],
|
||||
);
|
||||
const stats = await volunteerUsageStats(engine, ['default'], 30);
|
||||
expect(stats.approximate).toBe(true);
|
||||
expect(stats.note).toContain('approximate');
|
||||
expect(stats.total_volunteered).toBe(2);
|
||||
expect(stats.total_used).toBe(1);
|
||||
const alias = stats.by_arm.find((a) => a.match_arm === 'alias')!;
|
||||
expect(alias.used).toBe(1);
|
||||
expect(alias.precision).toBe(1);
|
||||
const title = stats.by_arm.find((a) => a.match_arm === 'title')!;
|
||||
expect(title.used).toBe(0);
|
||||
});
|
||||
|
||||
test('zero events → zeroed stats, not an error', async () => {
|
||||
const stats = await volunteerUsageStats(engine, ['default'], 7);
|
||||
expect(stats.total_volunteered).toBe(0);
|
||||
expect(stats.by_arm).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveEntitiesToPointers — new provenance surface (back-compat)', () => {
|
||||
test('pointers carry arm + confidence + source_id', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
||||
const block = await resolveEntitiesToPointers(
|
||||
engine,
|
||||
'default',
|
||||
[{ display: 'Alice Example', query: 'Alice Example' }],
|
||||
{},
|
||||
);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.pointers[0].arm).toBe('title');
|
||||
expect(block!.pointers[0].confidence).toBe(0.8);
|
||||
expect(block!.pointers[0].source_id).toBe('default');
|
||||
});
|
||||
|
||||
test('legacy suppression (slug-and-title) still drops a title mention in prior context', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'Founder.');
|
||||
const block = await resolveEntitiesToPointers(
|
||||
engine,
|
||||
'default',
|
||||
[{ display: 'Alice Example', query: 'Alice Example' }],
|
||||
{ priorContextText: 'we discussed Alice Example earlier' },
|
||||
);
|
||||
expect(block).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user