mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
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:
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user