From 18c552f415efa6d23335d69638fac27972c3ca16 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 29 May 2026 18:34:11 +0200 Subject: [PATCH] fix(mcp-search): rank+cap event-type candidates instead of legacy fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/__tests__/tools-search.test.ts | 29 ++++++++++++++++++- .../autopilot-monitor-mcp/src/tools/search.ts | 28 +++++++++++++----- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-search.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-search.test.ts index 6b2fd726..89e73f94 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-search.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-search.test.ts @@ -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>; 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([]); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts index 3bc3782e..36ed88b5 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts @@ -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. }