fix(mcp-search): rank+cap event-type candidates instead of legacy fallback

Completing the event-type catalog (130 types) exposed a latent regression: broad
keywords now match many types ("hello"→12, "failed"→6), and the cross-session walk
bailed to the weak legacy "5 recent failed sessions" path whenever candidates
exceeded MAX_EVENT_TYPE_CANDIDATES (10) — so common queries like "hello provisioning
failed" lost real cross-session recall.

Rank candidates by keyword-match count (most specific first), cap to the top-N, and
ALWAYS run the index walk when >=1 candidate; reserve the legacy path for the genuine
"no keyword maps to any event type" case. Dropped candidates flag truncated rather than
silently narrowing. extractEventTypeCandidates is now exported + unit-tested (ranking,
broad-keyword coverage >10, empty case).

MCP 112/112, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-29 18:34:11 +02:00
parent 469c692866
commit 18c552f415
2 changed files with 48 additions and 9 deletions
@@ -5,7 +5,12 @@
* and clock, so we drive it with canned pages instead of the live API.
*/
import { describe, it, expect } from 'vitest';
import { fetchEventsViaIndex, scoreEvent, queryHasProblemIntent } from '../tools/search.js';
import {
fetchEventsViaIndex,
scoreEvent,
queryHasProblemIntent,
extractEventTypeCandidates,
} from '../tools/search.js';
type Page = { events?: Array<Record<string, unknown>>; nextLink?: string };
@@ -216,3 +221,25 @@ describe('scoreEvent — severity-intent alignment', () => {
expect(scoreEvent({ eventType: 'network_state_change', severity: 'Info' }, keywords, true)).toBeNull();
});
});
describe('extractEventTypeCandidates — ranking + broad-catalog coverage', () => {
it('ranks the type matching the most keywords first', () => {
const ranked = extractEventTypeCandidates(['hello', 'provisioning', 'failed']);
// hello_provisioning_failed matches all three keywords → must rank #1.
expect(ranked[0]).toBe('hello_provisioning_failed');
});
it('returns the full ranked set (not pre-capped) so a broad keyword surfaces many types', () => {
// "hello" alone matches the whole hello_* family — well beyond the per-walk cap.
// The caller (fetchSessionEvents) caps to the top-N; this fn must NOT pre-truncate,
// otherwise the catalog-completion regression (legacy fallback) reappears.
const ranked = extractEventTypeCandidates(['hello']);
expect(ranked.length).toBeGreaterThan(10);
expect(ranked).toContain('hello_provisioning_failed');
expect(ranked.every((t) => t.includes('hello'))).toBe(true);
});
it('returns [] when no keyword maps to any event type (→ caller uses legacy path)', () => {
expect(extractEventTypeCandidates(['zzzznomatch'])).toEqual([]);
});
});
@@ -23,11 +23,18 @@ type EventEntry = {
*/
const KNOWN_EVENT_TYPES = ALL_EVENT_TYPES;
/** Match query keywords against known event types using prefix-aware matching. */
function extractEventTypeCandidates(keywords: string[]): string[] {
return KNOWN_EVENT_TYPES.filter((et) =>
keywords.some((kw) => prefixAwareMatch(et, kw)),
);
/**
* Rank known event types by how many query keywords they match (prefix-aware),
* most-relevant first. With the full ~130-type catalog a broad keyword (e.g. "hello"
* or "failed") matches many types; the caller caps to the top-N, so completing the
* catalog never starves the index walk into the weak legacy fallback.
*/
export function extractEventTypeCandidates(keywords: string[]): string[] {
return KNOWN_EVENT_TYPES
.map((et) => ({ et, hits: keywords.reduce((n, kw) => n + (prefixAwareMatch(et, kw) ? 1 : 0), 0) }))
.filter((c) => c.hits > 0)
.sort((a, b) => b.hits - a.hits)
.map((c) => c.et);
}
// ── Cross-session paginated fan-out ───────────────────────────────────────
@@ -169,15 +176,20 @@ async function fetchSessionEvents(
// Multi-session: paginated index walk per candidate event type.
if (queryKeywords && queryKeywords.length > 0) {
const candidates = extractEventTypeCandidates(queryKeywords);
if (candidates.length > 0 && candidates.length <= MAX_EVENT_TYPE_CANDIDATES) {
const ranked = extractEventTypeCandidates(queryKeywords);
if (ranked.length > 0) {
// Cap the per-type fan-out to the most-relevant types. If we dropped any,
// recall is partial → flag truncated rather than silently narrowing. (Legacy
// is reserved for the genuine "no keyword maps to any event type" case below.)
const candidates = ranked.slice(0, MAX_EVENT_TYPE_CANDIDATES);
const candidatesDropped = ranked.length > candidates.length;
const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events');
const { events, truncated, anySucceeded } = await fetchEventsViaIndex(candidates, basePath, tenantId, budget);
if (anySucceeded) {
// No event appears in more than one single-type walk, so the only
// overlap across walks is sessions — union them.
const sessionIds = [...new Set(events.map((e) => e._sessionId).filter(Boolean) as string[])];
return { events, sessionIds, searchMethod: 'index-paginated', truncated };
return { events, sessionIds, searchMethod: 'index-paginated', truncated: truncated || candidatesDropped };
}
// Every type errored before returning a page → fall through to legacy.
}