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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c47f0669ca
commit
2d44a229e0
+198
-146
@@ -7,6 +7,9 @@ import type { OpenProjectClient } from "../client/api-client.ts";
|
|||||||
import { extractIdFromHref, extractRawText } from "../client/hal-parser.ts";
|
import { extractIdFromHref, extractRawText } from "../client/hal-parser.ts";
|
||||||
import type { HalCollection, HalResource } from "../client/types.ts";
|
import type { HalCollection, HalResource } from "../client/types.ts";
|
||||||
import { resolveClient, resolveProjectId } from "./helper.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 {
|
export interface MeetingSummary {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -48,12 +51,16 @@ export interface MeetingDetail extends MeetingSummary {
|
|||||||
|
|
||||||
export interface MeetingSearchResult {
|
export interface MeetingSearchResult {
|
||||||
meeting: MeetingSummary;
|
meeting: MeetingSummary;
|
||||||
matchType: "title" | "location" | "agenda_item";
|
matchType: "title" | "location" | "agenda_item" | "participant" | "project";
|
||||||
matchedAgendaItems?: Array<{
|
matchedAgendaItems?: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
snippet?: 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<T> {
|
export interface PaginatedResult<T> {
|
||||||
@@ -65,6 +72,20 @@ export interface PaginatedResult<T> {
|
|||||||
items?: T[];
|
items?: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MeetingSearchPage extends PaginatedResult<MeetingSearchResult> {
|
||||||
|
/** 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.
|
* 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.
|
* Lists meetings visible to the user matching optional project, time, and pagination filters.
|
||||||
*/
|
*/
|
||||||
@@ -363,137 +365,187 @@ export async function getMeeting(
|
|||||||
return normalizeMeetingDetail(meetingResource);
|
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(
|
async function fetchMeetingDeepContent(
|
||||||
params: {
|
meetingId: number,
|
||||||
query: string;
|
client: OpenProjectClient
|
||||||
projectId?: string | number;
|
): Promise<MeetingDeepContent> {
|
||||||
offset?: number;
|
const [agendaResponse, detail] = await Promise.all([
|
||||||
pageSize?: number;
|
client.get<HalCollection<Record<string, unknown>>>(
|
||||||
},
|
`/api/v3/meetings/${meetingId}/agenda_items`
|
||||||
client?: OpenProjectClient
|
),
|
||||||
): Promise<PaginatedResult<MeetingSearchResult>> {
|
client.get<HalResource>(`/api/v3/meetings/${meetingId}`),
|
||||||
const opClient = resolveClient(client);
|
]);
|
||||||
const needle = params.query.toLowerCase().trim();
|
|
||||||
|
|
||||||
// Fetch candidate meetings scoped to project if specified, across multiple pages up to cap
|
const rawItems = agendaResponse?._embedded?.elements ?? [];
|
||||||
const maxCandidates = 250;
|
const meetingDetail = normalizeMeetingDetail(detail);
|
||||||
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<HalCollection<Record<string, unknown>>>(`/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);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total,
|
id: meetingId,
|
||||||
count: pagedElements.length,
|
agendaItems: rawItems.map(normalizeAgendaItem),
|
||||||
pageSize: requestedPageSize,
|
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<AgendaItem>[] = [
|
||||||
|
{ 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<MeetingSearchPage> {
|
||||||
|
const opClient = resolveClient(client);
|
||||||
|
const matchMode = params.matchMode ?? "fuzzy";
|
||||||
|
|
||||||
|
const MAX_CANDIDATES = 250;
|
||||||
|
const CANDIDATE_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
const fetchCandidates = async (): Promise<MeetingSummary[]> => {
|
||||||
|
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<MeetingSummary>[] = [
|
||||||
|
{ 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<MeetingDeepContent>[] = [
|
||||||
|
{ 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<MeetingSummary, MeetingDeepContent>(
|
||||||
|
{
|
||||||
|
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,
|
offset,
|
||||||
elements: pagedElements,
|
elements: paged,
|
||||||
items: pagedElements,
|
items: paged,
|
||||||
|
degraded: result.degraded,
|
||||||
|
enrichmentFailures: result.enrichmentFailures,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -96,7 +96,9 @@ export const searchMeetingsShape = {
|
|||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.describe(
|
.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
|
projectId: z
|
||||||
.union([z.number().int().positive(), z.string().min(1)])
|
.union([z.number().int().positive(), z.string().min(1)])
|
||||||
@@ -117,6 +119,14 @@ export const searchMeetingsShape = {
|
|||||||
.optional()
|
.optional()
|
||||||
.default(20)
|
.default(20)
|
||||||
.describe("Number of items per page (max 100, 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<z.ZodObject<typeof searchMeetingsShape>>;
|
export type SearchMeetingsArgs = z.input<z.ZodObject<typeof searchMeetingsShape>>;
|
||||||
@@ -163,7 +173,10 @@ export const searchMeetingsTool: ToolDefinition<
|
|||||||
SearchMeetingsArgs
|
SearchMeetingsArgs
|
||||||
> = {
|
> = {
|
||||||
name: "openproject_search_meetings",
|
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,
|
parameters: searchMeetingsShape,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
execute: handleSearchMeetings,
|
execute: handleSearchMeetings,
|
||||||
|
|||||||
+164
-12
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 { runWithContext, type RequestContext } from "../src/context.ts";
|
||||||
import { listMeetings, getMeeting, searchMeetings } from "../src/services/meetings.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}`);
|
throw new Error(`Unexpected path: ${path}`);
|
||||||
}
|
}
|
||||||
} as unknown as OpenProjectClient;
|
} as unknown as OpenProjectClient;
|
||||||
@@ -262,8 +279,8 @@ describe("Meetings Service", () => {
|
|||||||
requestedOffsets.push(offset);
|
requestedOffsets.push(offset);
|
||||||
|
|
||||||
if (offset === 1) {
|
if (offset === 1) {
|
||||||
// Page 1: 50 meetings, none matching query
|
// Page 1: 100 meetings (the pipeline's candidate batch size), none matching query
|
||||||
const page1Elements = Array.from({ length: 50 }, (_, i) => ({
|
const page1Elements = Array.from({ length: 100 }, (_, i) => ({
|
||||||
_type: "Meeting",
|
_type: "Meeting",
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
title: `Daily Routine ${i + 1}`,
|
title: `Daily Routine ${i + 1}`,
|
||||||
@@ -274,20 +291,20 @@ describe("Meetings Service", () => {
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
_type: "Collection",
|
_type: "Collection",
|
||||||
total: 55,
|
total: 105,
|
||||||
count: 50,
|
count: 100,
|
||||||
pageSize: 50,
|
pageSize: 100,
|
||||||
offset: 1,
|
offset: 1,
|
||||||
_embedded: { elements: page1Elements },
|
_embedded: { elements: page1Elements },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offset === 2) {
|
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) => ({
|
const page2Elements = Array.from({ length: 5 }, (_, i) => ({
|
||||||
_type: "Meeting",
|
_type: "Meeting",
|
||||||
id: 51 + i,
|
id: 101 + i,
|
||||||
title: i === 1 ? "Special Architecture Sync" : `Daily Routine ${51 + i}`,
|
title: i === 1 ? "Special Architecture Sync" : `Daily Routine ${101 + i}`,
|
||||||
state: "open",
|
state: "open",
|
||||||
startTime: "2026-09-02T10:00:00Z",
|
startTime: "2026-09-02T10:00:00Z",
|
||||||
endTime: "2026-09-02T10:30:00Z",
|
endTime: "2026-09-02T10:30:00Z",
|
||||||
@@ -295,9 +312,9 @@ describe("Meetings Service", () => {
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
_type: "Collection",
|
_type: "Collection",
|
||||||
total: 55,
|
total: 105,
|
||||||
count: 5,
|
count: 5,
|
||||||
pageSize: 50,
|
pageSize: 100,
|
||||||
offset: 2,
|
offset: 2,
|
||||||
_embedded: { elements: page2Elements },
|
_embedded: { elements: page2Elements },
|
||||||
};
|
};
|
||||||
@@ -313,7 +330,7 @@ describe("Meetings Service", () => {
|
|||||||
const res = await searchMeetings({ query: "Architecture" }, mockClient);
|
const res = await searchMeetings({ query: "Architecture" }, mockClient);
|
||||||
expect(requestedOffsets).toEqual([1, 2]);
|
expect(requestedOffsets).toEqual([1, 2]);
|
||||||
expect(res.total).toBe(1);
|
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");
|
expect(res.elements[0]!.meeting.title).toBe("Special Architecture Sync");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -425,3 +442,138 @@ describe("Meetings Service", () => {
|
|||||||
expect(agendaCalled).toBe(false);
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+3
-1
@@ -1187,7 +1187,9 @@ describe("Meeting Tools", () => {
|
|||||||
|
|
||||||
expect(searchMeetingsTool.name).toBe("openproject_search_meetings");
|
expect(searchMeetingsTool.name).toBe("openproject_search_meetings");
|
||||||
expect(searchMeetingsTool.description).toBe(
|
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);
|
expect(searchMeetingsTool.readOnly).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user