From 2d44a229e0a8832963225a175baf5987edb33c53 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 18 Sep 2026 07:40:49 +0200 Subject: [PATCH] feat(meetings): fuzzy-rank meeting search over agenda, participants, and project Routes searchMeetings through the shared searchPipeline instead of a hand-rolled Promise.all fan-out, fixing two live bugs: an expired token on agenda-item fetch previously produced "no results" (swallowed via .catch(() => undefined)) instead of propagating the auth error, and up to 250 candidate meetings could fire simultaneous agenda-item requests. The pipeline caps enrichment at 25 candidates with 8 in flight, and isFatalSearchError now lets 401/403/429 propagate while other failures degrade a single record and set `degraded`. Also removes the old substring-based extractSnippet from src/services/meetings.ts in favor of src/search/snippet.ts, and adds matchMode ("fuzzy" | "exact") to SearchMeetingsParams and the openproject_search_meetings tool schema. Updated two pre-existing tests in tests/meetings.test.ts whose fixtures assumed the old implementation's API surface: one didn't stub the meeting-detail endpoint the pipeline's deep enrichment now also fetches (for participants), and one assumed a 50-item candidate page size where the new pipeline hardcodes 100. Updated one assertion in tests/tools.test.ts that checked the old literal tool description. Co-Authored-By: Claude Opus 5 (1M context) --- src/services/meetings.ts | 344 ++++++++++++++++++++++----------------- src/tools/meetings.ts | 17 +- tests/meetings.test.ts | 176 ++++++++++++++++++-- tests/tools.test.ts | 4 +- 4 files changed, 380 insertions(+), 161 deletions(-) diff --git a/src/services/meetings.ts b/src/services/meetings.ts index 8ba8d9c..d92e81d 100644 --- a/src/services/meetings.ts +++ b/src/services/meetings.ts @@ -7,6 +7,9 @@ import type { OpenProjectClient } from "../client/api-client.ts"; import { extractIdFromHref, extractRawText } from "../client/hal-parser.ts"; import type { HalCollection, HalResource } from "../client/types.ts"; import { resolveClient, resolveProjectId } from "./helper.ts"; +import { searchPipeline } from "../search/pipeline.ts"; +import { rankRecords } from "../search/rank.ts"; +import type { FieldSpec, MatchMode } from "../search/rank.ts"; export interface MeetingSummary { id: number; @@ -48,12 +51,16 @@ export interface MeetingDetail extends MeetingSummary { export interface MeetingSearchResult { meeting: MeetingSummary; - matchType: "title" | "location" | "agenda_item"; + matchType: "title" | "location" | "agenda_item" | "participant" | "project"; matchedAgendaItems?: Array<{ id: number; title: string; snippet?: string; }>; + /** Relevance score in the range 0..1. */ + score: number; + /** Names of the fields that matched, highest scoring first. */ + matchedFields: string[]; } export interface PaginatedResult { @@ -65,6 +72,20 @@ export interface PaginatedResult { items?: T[]; } +export interface MeetingSearchPage extends PaginatedResult { + /** True when some deep content could not be read. */ + degraded: boolean; + enrichmentFailures: number; +} + +export interface SearchMeetingsParams { + query: string; + projectId?: string | number; + offset?: number; + pageSize?: number; + matchMode?: MatchMode; +} + /** * Normalizes a raw HAL Meeting resource into a token-efficient MeetingSummary. */ @@ -244,25 +265,6 @@ export function normalizeMeetingDetail( }; } -/** - * Extracts a concise text snippet centered around query match keyword. - */ -function extractSnippet(text: string, query: string, maxLength = 160): string { - if (!text) return ""; - const lowerText = text.toLowerCase(); - const lowerQuery = query.toLowerCase(); - const idx = lowerText.indexOf(lowerQuery); - if (idx === -1) { - return text.length <= maxLength ? text : `${text.slice(0, maxLength)}...`; - } - const start = Math.max(0, idx - 40); - const end = Math.min(text.length, idx + query.length + 80); - let snippet = text.slice(start, end).trim(); - if (start > 0) snippet = `...${snippet}`; - if (end < text.length) snippet = `${snippet}...`; - return snippet; -} - /** * Lists meetings visible to the user matching optional project, time, and pagination filters. */ @@ -363,137 +365,187 @@ export async function getMeeting( return normalizeMeetingDetail(meetingResource); } +interface MeetingDeepContent { + id: number; + agendaItems: AgendaItem[]; + participants: Array<{ id: number; name: string }>; +} + /** - * Deep searches across meetings by title, location, and agenda item notes. + * Fetches agenda items and participants for one meeting. */ -export async function searchMeetings( - params: { - query: string; - projectId?: string | number; - offset?: number; - pageSize?: number; - }, - client?: OpenProjectClient -): Promise> { - const opClient = resolveClient(client); - const needle = params.query.toLowerCase().trim(); +async function fetchMeetingDeepContent( + meetingId: number, + client: OpenProjectClient +): Promise { + const [agendaResponse, detail] = await Promise.all([ + client.get>>( + `/api/v3/meetings/${meetingId}/agenda_items` + ), + client.get(`/api/v3/meetings/${meetingId}`), + ]); - // Fetch candidate meetings scoped to project if specified, across multiple pages up to cap - const maxCandidates = 250; - const candidateBatchSize = Math.min(maxCandidates, Math.max(params.pageSize ?? 50, 50)); - const candidateMeetings: MeetingSummary[] = []; - let candidateOffset = 1; - - while (candidateMeetings.length < maxCandidates) { - const meetingsResult = await listMeetings( - { - projectId: params.projectId, - offset: candidateOffset, - pageSize: candidateBatchSize, - }, - opClient - ); - - const elements = meetingsResult.elements; - if (elements.length === 0) { - break; - } - - candidateMeetings.push(...elements); - - // Stop if all meetings fetched or fewer elements returned than requested page size - if ( - candidateMeetings.length >= meetingsResult.total || - elements.length < candidateBatchSize - ) { - break; - } - - candidateOffset += 1; - } - - const scopedCandidates = candidateMeetings.slice(0, maxCandidates); - - // Process candidate meetings concurrently - const matchPromises = scopedCandidates.map(async (meeting) => { - const titleMatches = meeting.title.toLowerCase().includes(needle); - const locationMatches = Boolean( - meeting.location && meeting.location.toLowerCase().includes(needle) - ); - - // Concurrently fetch agenda items for candidate meeting - const agendaResponse = await opClient - .get>>(`/api/v3/meetings/${meeting.id}/agenda_items`) - .catch(() => undefined); - - const rawItems = agendaResponse?._embedded?.elements ?? []; - const agendaItems = rawItems.map(normalizeAgendaItem); - - const matchedAgendaItems: Array<{ id: number; title: string; snippet?: string }> = []; - for (const item of agendaItems) { - const itemTitleMatches = item.title.toLowerCase().includes(needle); - const itemNotesMatches = Boolean(item.notes && item.notes.toLowerCase().includes(needle)); - const outcomeNotesMatch = item.outcomes.find((o) => - o.notes.toLowerCase().includes(needle) - ); - - if (itemTitleMatches || itemNotesMatches || outcomeNotesMatch) { - let snippetText = ""; - if (itemNotesMatches && item.notes) { - snippetText = extractSnippet(item.notes, params.query); - } else if (outcomeNotesMatch) { - snippetText = extractSnippet(outcomeNotesMatch.notes, params.query); - } else { - snippetText = item.notes ? extractSnippet(item.notes, params.query) : item.title; - } - - matchedAgendaItems.push({ - id: item.id, - title: item.title, - snippet: snippetText || undefined, - }); - } - } - - let matchType: "title" | "location" | "agenda_item" | null = null; - if (titleMatches) { - matchType = "title"; - } else if (locationMatches) { - matchType = "location"; - } else if (matchedAgendaItems.length > 0) { - matchType = "agenda_item"; - } - - if (!matchType) { - return null; - } - - const searchResult: MeetingSearchResult = { - meeting, - matchType, - matchedAgendaItems: matchedAgendaItems.length > 0 ? matchedAgendaItems : undefined, - }; - - return searchResult; - }); - - const resolvedMatches = await Promise.all(matchPromises); - const matchedElements: MeetingSearchResult[] = resolvedMatches.filter( - (item): item is MeetingSearchResult => item !== null - ); - - const total = matchedElements.length; - const offset = params.offset ?? 1; - const requestedPageSize = params.pageSize ?? 20; - const startIndex = Math.max(0, offset - 1); - const pagedElements = matchedElements.slice(startIndex, startIndex + requestedPageSize); + const rawItems = agendaResponse?._embedded?.elements ?? []; + const meetingDetail = normalizeMeetingDetail(detail); return { - total, - count: pagedElements.length, - pageSize: requestedPageSize, + id: meetingId, + agendaItems: rawItems.map(normalizeAgendaItem), + participants: meetingDetail.participants, + }; +} + +/** + * Maps matched field names onto the legacy matchType discriminator, in + * priority order. + */ +function resolveMatchType(matchedFields: string[]): MeetingSearchResult["matchType"] { + if (matchedFields.includes("title")) return "title"; + if (matchedFields.includes("location")) return "location"; + if ( + matchedFields.includes("agenda_item") || + matchedFields.includes("agenda_notes") || + matchedFields.includes("outcome_notes") + ) { + return "agenda_item"; + } + if (matchedFields.includes("participant")) return "participant"; + return "project"; +} + +/** + * Ranks a meeting's agenda items against the query directly. + * + * Do NOT try to work out which item matched by searching the meeting-level + * snippet: snippets are trimmed and ellipsized, so the original text is not + * recoverable from them. Ranking the items themselves gives exact per-item + * scores and snippets for free. + */ +function collectMatchedAgendaItems( + deep: MeetingDeepContent | undefined, + query: string, + matchMode: MatchMode +): Array<{ id: number; title: string; snippet?: string }> { + if (!deep || deep.agendaItems.length === 0) { + return []; + } + + const fields: FieldSpec[] = [ + { name: "title", weight: 2, extract: (item) => item.title }, + { name: "notes", weight: 1.5, extract: (item) => item.notes }, + { + name: "outcomes", + weight: 1.5, + extract: (item) => item.outcomes.map((outcome) => outcome.notes), + }, + ]; + + return rankRecords(deep.agendaItems, query, fields, { matchMode }).map((entry) => ({ + id: entry.record.id, + title: entry.record.title, + snippet: entry.matches[0]?.snippet, + })); +} + +/** + * Deep searches meetings across titles, locations, agenda items, + * participants, and project names using fuzzy matching. + */ +export async function searchMeetings( + params: SearchMeetingsParams, + client?: OpenProjectClient +): Promise { + const opClient = resolveClient(client); + const matchMode = params.matchMode ?? "fuzzy"; + + const MAX_CANDIDATES = 250; + const CANDIDATE_BATCH_SIZE = 100; + + const fetchCandidates = async (): Promise => { + const collected: MeetingSummary[] = []; + let offset = 1; + + while (collected.length < MAX_CANDIDATES) { + const page = await listMeetings( + { projectId: params.projectId, offset, pageSize: CANDIDATE_BATCH_SIZE }, + opClient + ); + + if (page.elements.length === 0) { + break; + } + collected.push(...page.elements); + + if (collected.length >= page.total || page.elements.length < CANDIDATE_BATCH_SIZE) { + break; + } + offset += 1; + } + + return collected.slice(0, MAX_CANDIDATES); + }; + + const shallowFields: FieldSpec[] = [ + { name: "title", weight: 3, extract: (m) => m.title }, + { name: "location", weight: 1, extract: (m) => m.location }, + { name: "project", weight: 0.5, extract: (m) => m.project.name }, + { name: "author", weight: 0.5, extract: (m) => m.author?.name }, + ]; + + const deepFields: FieldSpec[] = [ + { name: "agenda_item", weight: 2, extract: (d) => d.agendaItems.map((i) => i.title) }, + { + name: "agenda_notes", + weight: 1.5, + extract: (d) => d.agendaItems.map((i) => i.notes ?? "").filter((n) => n.length > 0), + }, + { + name: "outcome_notes", + weight: 1.5, + extract: (d) => d.agendaItems.flatMap((i) => i.outcomes.map((o) => o.notes)), + }, + { name: "participant", weight: 1, extract: (d) => d.participants.map((p) => p.name) }, + ]; + + const result = await searchPipeline( + { + fetchCandidates, + shallowFields, + enrich: (meeting) => fetchMeetingDeepContent(meeting.id, opClient), + deepFields, + recencyOf: (meeting) => meeting.startTime, + idOf: (meeting) => meeting.id, + }, + params.query, + { matchMode } + ); + + const all: MeetingSearchResult[] = result.ranked.map((entry) => { + const matchedFields = entry.matches.map((match) => match.field); + const matchedAgendaItems = collectMatchedAgendaItems(entry.deep, params.query, matchMode); + + return { + meeting: entry.record, + matchType: resolveMatchType(matchedFields), + matchedAgendaItems: matchedAgendaItems.length > 0 ? matchedAgendaItems : undefined, + score: entry.score, + matchedFields, + }; + }); + + const offset = params.offset ?? 1; + const pageSize = params.pageSize ?? 20; + const startIndex = Math.max(0, offset - 1); + const paged = all.slice(startIndex, startIndex + pageSize); + + return { + total: all.length, + count: paged.length, + pageSize, offset, - elements: pagedElements, - items: pagedElements, + elements: paged, + items: paged, + degraded: result.degraded, + enrichmentFailures: result.enrichmentFailures, }; } diff --git a/src/tools/meetings.ts b/src/tools/meetings.ts index b64a9e4..5d6d16f 100644 --- a/src/tools/meetings.ts +++ b/src/tools/meetings.ts @@ -96,7 +96,9 @@ export const searchMeetingsShape = { .string() .min(1) .describe( - "Search keywords to match across meeting titles, locations, and agenda item notes" + "Search keywords. Natural-language phrasing works; matched against meeting " + + "titles, locations, project and author names, agenda item titles and notes, " + + "meeting outcomes, and participant names." ), projectId: z .union([z.number().int().positive(), z.string().min(1)]) @@ -117,6 +119,14 @@ export const searchMeetingsShape = { .optional() .default(20) .describe("Number of items per page (max 100, default 20)"), + matchMode: z + .enum(["fuzzy", "exact"]) + .optional() + .default("fuzzy") + .describe( + "Matching strategy. 'fuzzy' (default) tolerates typos, word reordering, and " + + "partial words. Use 'exact' only for literal strings you know verbatim." + ), }; export type SearchMeetingsArgs = z.input>; @@ -163,7 +173,10 @@ export const searchMeetingsTool: ToolDefinition< SearchMeetingsArgs > = { name: "openproject_search_meetings", - description: "Search across meetings and agenda items by keywords.", + description: + "Search meetings and agenda items. Accepts natural-language phrasing and " + + "tolerates typos and reordered words. Returns results ranked by relevance " + + "with matching excerpts.", parameters: searchMeetingsShape, readOnly: true, execute: handleSearchMeetings, diff --git a/tests/meetings.test.ts b/tests/meetings.test.ts index ab24389..62782a7 100644 --- a/tests/meetings.test.ts +++ b/tests/meetings.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { OpenProjectClient } from "../src/client/api-client.ts"; +import { + OpenProjectAuthenticationError, + OpenProjectClient, + OpenProjectError, +} from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; import { listMeetings, getMeeting, searchMeetings } from "../src/services/meetings.ts"; @@ -132,6 +136,19 @@ describe("Meetings Service", () => { } }; } + if (path === "/api/v3/meetings/2") { + return { + _type: "Meeting", + id: 2, + title: "Weekly Planning", + state: "open", + startTime: "2026-09-08T00:22:53Z", + endTime: "2026-09-08T01:22:53Z", + _links: { + project: { href: "/api/v3/projects/1", title: "Demo project" } + } + }; + } throw new Error(`Unexpected path: ${path}`); } } as unknown as OpenProjectClient; @@ -262,8 +279,8 @@ describe("Meetings Service", () => { requestedOffsets.push(offset); if (offset === 1) { - // Page 1: 50 meetings, none matching query - const page1Elements = Array.from({ length: 50 }, (_, i) => ({ + // Page 1: 100 meetings (the pipeline's candidate batch size), none matching query + const page1Elements = Array.from({ length: 100 }, (_, i) => ({ _type: "Meeting", id: i + 1, title: `Daily Routine ${i + 1}`, @@ -274,20 +291,20 @@ describe("Meetings Service", () => { })); return { _type: "Collection", - total: 55, - count: 50, - pageSize: 50, + total: 105, + count: 100, + pageSize: 100, offset: 1, _embedded: { elements: page1Elements }, }; } if (offset === 2) { - // Page 2: 5 meetings, meeting #52 has matching title + // Page 2: 5 meetings, meeting #102 has matching title const page2Elements = Array.from({ length: 5 }, (_, i) => ({ _type: "Meeting", - id: 51 + i, - title: i === 1 ? "Special Architecture Sync" : `Daily Routine ${51 + i}`, + id: 101 + i, + title: i === 1 ? "Special Architecture Sync" : `Daily Routine ${101 + i}`, state: "open", startTime: "2026-09-02T10:00:00Z", endTime: "2026-09-02T10:30:00Z", @@ -295,9 +312,9 @@ describe("Meetings Service", () => { })); return { _type: "Collection", - total: 55, + total: 105, count: 5, - pageSize: 50, + pageSize: 100, offset: 2, _embedded: { elements: page2Elements }, }; @@ -313,7 +330,7 @@ describe("Meetings Service", () => { const res = await searchMeetings({ query: "Architecture" }, mockClient); expect(requestedOffsets).toEqual([1, 2]); expect(res.total).toBe(1); - expect(res.elements[0]!.meeting.id).toBe(52); + expect(res.elements[0]!.meeting.id).toBe(102); expect(res.elements[0]!.meeting.title).toBe("Special Architecture Sync"); }); @@ -425,3 +442,138 @@ describe("Meetings Service", () => { expect(agendaCalled).toBe(false); }); }); + +describe("Meetings Fuzzy Search", () => { + function meetingFixtureClient() { + return { + get: async (path: string) => { + if (path.startsWith("/api/v3/meetings?") || path === "/api/v3/meetings") { + return { + _type: "Collection", + total: 2, + count: 2, + pageSize: 50, + offset: 1, + _embedded: { + elements: [ + { + _type: "Meeting", + id: 1, + title: "Approval of Q3 budget", + state: "open", + startTime: "2026-09-08T00:00:00Z", + endTime: "2026-09-08T01:00:00Z", + location: "Room A", + _links: { + project: { href: "/api/v3/projects/1", title: "Demo project" }, + author: { href: "/api/v3/users/4", title: "Müller" }, + }, + }, + { + _type: "Meeting", + id: 2, + title: "Weekly sync", + state: "open", + startTime: "2026-09-09T00:00:00Z", + endTime: "2026-09-09T01:00:00Z", + _links: { + project: { href: "/api/v3/projects/1", title: "Demo project" }, + }, + }, + ], + }, + }; + } + if (path === "/api/v3/meetings/1/agenda_items") { + return { _type: "Collection", _embedded: { elements: [] } }; + } + if (path === "/api/v3/meetings/2/agenda_items") { + return { + _type: "Collection", + _embedded: { + elements: [ + { + _type: "MeetingAgendaItem", + id: 20, + title: "Staffing", + notes: { raw: "We agreed on the hiring freeze until January." }, + position: 1, + }, + ], + }, + }; + } + return { _type: "Collection", _embedded: { elements: [] } }; + }, + } as unknown as OpenProjectClient; + } + + test("finds a meeting despite a typo in the query", async () => { + const result = await searchMeetings({ query: "budgt aproval" }, meetingFixtureClient()); + expect(result.elements[0]!.meeting.id).toBe(1); + expect(result.elements[0]!.score).toBeGreaterThan(0); + }); + + test("finds a meeting with reordered query words", async () => { + const result = await searchMeetings({ query: "budget approval" }, meetingFixtureClient()); + expect(result.elements[0]!.meeting.id).toBe(1); + }); + + test("finds a meeting by agenda item notes only", async () => { + const result = await searchMeetings({ query: "hiring freeze" }, meetingFixtureClient()); + expect(result.elements[0]!.meeting.id).toBe(2); + expect(result.elements[0]!.matchType).toBe("agenda_item"); + expect(result.elements[0]!.matchedAgendaItems?.[0]!.snippet).toContain("hiring freeze"); + }); + + test("matches an author name with diacritics", async () => { + const result = await searchMeetings({ query: "muller" }, meetingFixtureClient()); + expect(result.elements.some((e) => e.meeting.id === 1)).toBe(true); + }); + + test("exact mode finds nothing for a typo", async () => { + const result = await searchMeetings( + { query: "budgt aproval", matchMode: "exact" }, + meetingFixtureClient() + ); + expect(result.elements).toHaveLength(0); + }); + + test("exact mode still finds a literal substring", async () => { + const result = await searchMeetings( + { query: "Q3 budget", matchMode: "exact" }, + meetingFixtureClient() + ); + expect(result.elements[0]!.meeting.id).toBe(1); + }); + + test("reports degradation rather than silently returning fewer results", async () => { + const client = { + get: async (path: string) => { + if (path.includes("agenda_items")) { + throw new OpenProjectError("server error", { statusCode: 500 }); + } + return meetingFixtureClient().get(path); + }, + } as unknown as OpenProjectClient; + + const result = await searchMeetings({ query: "budget" }, client); + expect(result.degraded).toBe(true); + expect(result.enrichmentFailures).toBeGreaterThan(0); + }); + + test("propagates an auth failure instead of reporting no results", async () => { + const client = { + get: async (path: string) => { + if (path.includes("agenda_items")) { + throw new OpenProjectAuthenticationError(); + } + return meetingFixtureClient().get(path); + }, + } as unknown as OpenProjectClient; + + await expect(searchMeetings({ query: "budget" }, client)).rejects.toThrow( + OpenProjectAuthenticationError + ); + }); +}); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 188b459..eefe7cc 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1187,7 +1187,9 @@ describe("Meeting Tools", () => { expect(searchMeetingsTool.name).toBe("openproject_search_meetings"); expect(searchMeetingsTool.description).toBe( - "Search across meetings and agenda items by keywords." + "Search meetings and agenda items. Accepts natural-language phrasing and " + + "tolerates typos and reordered words. Returns results ranked by relevance " + + "with matching excerpts." ); expect(searchMeetingsTool.readOnly).toBe(true); });