feat(mcp-search): session-diversified results, looser budgets, honest legacy cap

Three recall/usability improvements surfaced by live testing the now-complete catalog:

- Session diversity: cross-session results were dominated by one session's many
  same-type events (the index returns a session's events contiguously), so the same
  session kept showing up — useless for comparison. diversifyBySession spreads the
  top-K across distinct sessions (max 2/session, then backfill). Guarantee: the #1 hit
  and each session's top-2 are always kept; only a dominant session's 3rd+ hit is
  demoted. Single-session (scoped) searches are unaffected.
- Looser fan-out budgets so the complete catalog isn't constantly truncated:
  DEEP 8→20 pages / 20s→40s, SEMANTIC 1→3 pages / 8s→12s, MAX candidates 10→16.
- Legacy fallback now sends pageSize (the honored param; `limit` was removed in the
  pagination rollout and was a silent no-op returning the full ~50-row page) and slices
  client-side to 5, so the recallNote is accurate and the N+1 fan-out stays bounded.

diversifyBySession + extractEventTypeCandidates exported and unit-tested. MCP 115/115.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-29 18:55:44 +02:00
parent 18c552f415
commit 28742509ec
2 changed files with 91 additions and 12 deletions
@@ -10,6 +10,7 @@ import {
scoreEvent,
queryHasProblemIntent,
extractEventTypeCandidates,
diversifyBySession,
} from '../tools/search.js';
type Page = { events?: Array<Record<string, unknown>>; nextLink?: string };
@@ -243,3 +244,27 @@ describe('extractEventTypeCandidates — ranking + broad-catalog coverage', () =
expect(extractEventTypeCandidates(['zzzznomatch'])).toEqual([]);
});
});
describe('diversifyBySession', () => {
const ev = (sid: string, score: number) => ({ event: { _sessionId: sid }, score });
it('surfaces a second session instead of a third event from the same one', () => {
// Score order: A, A, A, B, C. Default perSession=2 → A,A then B.
const scored = [ev('A', 9), ev('A', 8), ev('A', 7), ev('B', 6), ev('C', 5)];
const out = diversifyBySession(scored, 3);
expect(out.map((s) => s.event._sessionId)).toEqual(['A', 'A', 'B']);
});
it('backfills (never drops) when distinct sessions run out', () => {
const scored = [ev('A', 9), ev('A', 8), ev('A', 7)];
const out = diversifyBySession(scored, 3);
expect(out).toHaveLength(3); // single session → same as a plain top-K slice
expect(out.map((s) => s.event._sessionId)).toEqual(['A', 'A', 'A']);
});
it('respects topK and preserves score order within the picked set', () => {
const scored = [ev('A', 9), ev('B', 8), ev('A', 7), ev('C', 6)];
const out = diversifyBySession(scored, 2);
expect(out.map((s) => s.event._sessionId)).toEqual(['A', 'B']);
});
});
@@ -60,13 +60,23 @@ type FetchBudget = { maxPagesPerType: number; wallClockMs: number };
const RAW_EVENTS_PAGE_SIZE = 200;
/** Upper bound on distinct event types to fan out over (keeps the request count sane). */
const MAX_EVENT_TYPE_CANDIDATES = 10;
const MAX_EVENT_TYPE_CANDIDATES = 16;
/** Tier-1 (fast): one page per type — already ≈20x the legacy sessionLimit:10, but cheap. */
const SEMANTIC_BUDGET: FetchBudget = { maxPagesPerType: 1, wallClockMs: 8_000 };
/**
* Hard cap on sessions scanned by the legacy fallback. The backend /search/sessions
* endpoint bounds results via `pageSize` (the old `limit` param was removed in the
* pagination rollout — passing it was a silent no-op that returned the full ~50-row
* default page). We send pageSize AND slice client-side as defense, so the N+1
* event-fetch fallback stays bounded and the recallNote ("5 most recent failed
* sessions") stays honest.
*/
const LEGACY_FALLBACK_SESSION_CAP = 5;
/** Tier-3 (deep): many pages per type for broad recall, bounded by wall-clock. */
const DEEP_BUDGET: FetchBudget = { maxPagesPerType: 8, wallClockMs: 20_000 };
/** Tier-1 (fast): a couple pages per type — still quick, but less easily truncated. */
const SEMANTIC_BUDGET: FetchBudget = { maxPagesPerType: 3, wallClockMs: 12_000 };
/** Tier-3 (deep): wide multi-page recall, bounded by a generous wall-clock. */
const DEEP_BUDGET: FetchBudget = { maxPagesPerType: 20, wallClockMs: 40_000 };
/** Injectable page fetcher — production hits the backend; tests pass a fake. */
type PageFetcher = (path: string) => Promise<RawEventsPage>;
@@ -195,17 +205,22 @@ async function fetchSessionEvents(
}
}
// Fallback: scan the 5 most recent failed sessions (legacy N+1 path). Only
// reached when no event-type candidates exist or the index path produced
// nothing — recallSummary() flags this as incomplete.
const searchParams: Record<string, string | number | undefined> = { status: 'Failed', limit: 5 };
// Fallback: scan the most recent failed sessions (legacy N+1 path). Only reached
// when no event-type candidates exist or the index path produced nothing —
// recallSummary() flags this as incomplete.
const searchParams: Record<string, string | number | undefined> = { status: 'Failed', pageSize: LEGACY_FALLBACK_SESSION_CAP };
if (tenantId) searchParams.tenantId = tenantId;
const searchQ = buildQuery(searchParams);
const searchBase = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions');
const sessions = await apiFetch(`${searchBase}${searchQ}`) as {
sessions?: Array<{ sessionId?: string }>;
};
const ids = (sessions?.sessions ?? []).map((s) => s.sessionId).filter(Boolean) as string[];
// The backend ignores `limit`, so cap client-side to keep the N+1 fan-out bounded
// and the recallNote honest.
const ids = (sessions?.sessions ?? [])
.map((s) => s.sessionId)
.filter(Boolean)
.slice(0, LEGACY_FALLBACK_SESSION_CAP) as string[];
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
const allEvents = await Promise.all(
ids.map(async (sid) => {
@@ -385,6 +400,45 @@ export function scoreEvent(e: EventEntry, queryKeywords: string[], boostFailures
};
}
/**
* Spread the top-scored events across distinct sessions so one session's many
* same-type events don't monopolize the list — cross-session search otherwise keeps
* surfacing the same session first (the index returns a session's events contiguously).
*
* Greedy in score order: take up to `perSession` per session, then backfill remaining
* slots in score order. GUARANTEE: the strongest hit is always kept and ranked first,
* and the top `perSession` hits of every session are always kept — a clear top match is
* never lost. Only the 3rd+ hit of a dominant session is demoted, and at small topK it
* may be displaced by another session's lower-scored hit — the deliberate cost of
* diversity. Single-session (scoped) searches are unaffected: backfill restores pure
* score order, identical to a plain top-K slice.
*/
export function diversifyBySession<T extends { event: EventEntry }>(
scoredDesc: T[],
topK: number,
perSession = 2,
): T[] {
const counts = new Map<string, number>();
const picked: T[] = [];
const deferred: T[] = [];
for (const s of scoredDesc) {
if (picked.length >= topK) break;
const sid = s.event._sessionId ?? s.event.sessionId ?? '';
const c = counts.get(sid) ?? 0;
if (c < perSession) {
picked.push(s);
counts.set(sid, c + 1);
} else {
deferred.push(s);
}
}
for (const s of deferred) {
if (picked.length >= topK) break;
picked.push(s);
}
return picked;
}
// ── Registration ────────────────────────────────────────────────────────
export function registerSearchTools(server: McpServer, knowledgeBase: SearchProvider | undefined, ga: boolean): void {
@@ -439,7 +493,7 @@ export function registerSearchTools(server: McpServer, knowledgeBase: SearchProv
}
scored.sort((a, b) => b.score - a.score);
const results = scored.slice(0, topK).map((s) => ({
const results = diversifyBySession(scored, topK).map((s) => ({
score: Math.round(s.score * 1000) / 1000,
matchedKeywords: s.matchedKeywords,
bestFields: s.bestFields,
@@ -605,7 +659,7 @@ export function registerSearchTools(server: McpServer, knowledgeBase: SearchProv
}
scored.sort((a, b) => b.score - a.score);
const results = scored.slice(0, topK).map((s) => ({
const results = diversifyBySession(scored, topK).map((s) => ({
score: Math.round(s.score * 1000) / 1000,
matchedKeywords: s.matchedKeywords,
bestFields: s.bestFields,