diff --git a/src/services/wikis.ts b/src/services/wikis.ts index 940b237..21da48f 100644 --- a/src/services/wikis.ts +++ b/src/services/wikis.ts @@ -7,9 +7,10 @@ import { OpenProjectAuthenticationError, type OpenProjectClient, } from "../client/api-client.ts"; -import { extractIdFromHref } from "../client/hal-parser.ts"; +import { extractIdFromHref, extractRawText } from "../client/hal-parser.ts"; import type { HalCollection, HalLink, HalResource } from "../client/types.ts"; import { resolveClient, resolveProjectId } from "./helper.ts"; +import { rankRecords, type FieldSpec, type MatchMode } from "../search/rank.ts"; export interface WikiPageAttachment { id: number; @@ -22,6 +23,8 @@ export interface WikiPageAttachment { export interface WikiPageDetail { id: number; title: string; + /** Raw markdown body from the formattable `text` field. */ + text?: string; project: { id: number; identifier?: string; name?: string }; attachments: WikiPageAttachment[]; } @@ -61,8 +64,16 @@ export interface SearchWikiPagesParams { projectId?: string | number; limit?: number; refreshCache?: boolean; + matchMode?: MatchMode; } +export type WikiPageSearchResult = WikiPageSummary & { + /** Relevance score in the range 0..1. */ + score: number; + snippet?: string; + matchedFields: string[]; +}; + // In-memory cache for discovered wiki pages keyed by client cache key const wikiPagesCache = new Map>(); const discoveredCacheKeys = new Set(); @@ -119,10 +130,12 @@ export function normalizeWikiPageDetail( const id = resource.id ?? extractIdFromHref(links.self?.href) ?? 0; const title = String(resource.title ?? ""); + const text = extractRawText(resource.text); return { id, title, + ...(text ? { text } : {}), project, attachments, }; @@ -257,7 +270,7 @@ export async function listWikiPageLinks( export async function searchWikiPages( params?: SearchWikiPagesParams, client?: OpenProjectClient -): Promise { +): Promise { const opClient = resolveClient(client); const cacheKey = opClient.getCacheKey?.() ?? (opClient.baseUrl || "default"); @@ -364,24 +377,39 @@ export async function searchWikiPages( targetProjectId = await resolveProjectId(params.projectId, opClient); } - // 4. Filter cached pages - const allPages = Array.from(cacheMap.values()); - const querySubstring = params?.query?.trim().toLowerCase(); + // 4. Scope cached pages to the target project + const allPages = Array.from(cacheMap.values()).filter( + (page) => targetProjectId === undefined || page.project.id === targetProjectId + ); - let filtered = allPages.filter((page) => { - if (targetProjectId !== undefined && page.project.id !== targetProjectId) { - return false; - } - if (querySubstring && !page.title.toLowerCase().includes(querySubstring)) { - return false; - } - return true; - }); - - // 5. Apply limit - if (params?.limit !== undefined && params.limit > 0) { - filtered = filtered.slice(0, params.limit); + // 5. Rank by relevance + const query = params?.query?.trim() ?? ""; + if (query.length === 0) { + const limited = + params?.limit !== undefined && params.limit > 0 + ? allPages.slice(0, params.limit) + : allPages; + return limited.map((page) => ({ + ...toWikiPageSummary(page), + score: 0, + matchedFields: [], + })); } - return filtered.map(toWikiPageSummary); + const fields: FieldSpec[] = [ + { name: "title", weight: 3, extract: (page) => page.title }, + { name: "text", weight: 1, extract: (page) => page.text }, + ]; + + const ranked = rankRecords(allPages, query, fields, { + matchMode: params?.matchMode ?? "fuzzy", + limit: params?.limit, + }); + + return ranked.map((entry) => ({ + ...toWikiPageSummary(entry.record), + score: entry.score, + snippet: entry.matches[0]?.snippet, + matchedFields: entry.matches.map((match) => match.field), + })); } diff --git a/src/tools/wikis.ts b/src/tools/wikis.ts index d13a9a8..79b1f1a 100644 --- a/src/tools/wikis.ts +++ b/src/tools/wikis.ts @@ -46,7 +46,10 @@ export const searchWikiPagesShape = { query: z .string() .optional() - .describe("Substring keyword to match in wiki page title"), + .describe( + "Search keywords matched against wiki page titles and page body text. " + + "Natural-language phrasing works." + ), projectId: z .union([z.number().int().positive(), z.string().min(1)]) .optional() @@ -64,6 +67,14 @@ export const searchWikiPagesShape = { .optional() .default(false) .describe("Force cache refresh"), + 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 SearchWikiPagesArgs = z.input>; @@ -140,7 +151,9 @@ export const searchWikiPagesTool: ToolDefinition< SearchWikiPagesArgs > = { name: "openproject_search_wiki_pages", - description: "Discover and search wiki pages matching keywords or project.", + description: + "Search wiki pages by title and page content. Accepts natural-language " + + "phrasing and tolerates typos. Returns results ranked by relevance.", parameters: searchWikiPagesShape, readOnly: true, execute: handleSearchWikiPages, diff --git a/tests/tools.test.ts b/tests/tools.test.ts index eefe7cc..3bf4b04 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1464,7 +1464,8 @@ describe("Wikis Tools", () => { expect(searchWikiPagesTool.name).toBe("openproject_search_wiki_pages"); expect(searchWikiPagesTool.description).toBe( - "Discover and search wiki pages matching keywords or project." + "Search wiki pages by title and page content. Accepts natural-language " + + "phrasing and tolerates typos. Returns results ranked by relevance." ); expect(searchWikiPagesTool.readOnly).toBe(true); diff --git a/tests/wikis.test.ts b/tests/wikis.test.ts index c2263b6..fc03470 100644 --- a/tests/wikis.test.ts +++ b/tests/wikis.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach } from "bun:test"; import { OpenProjectAuthenticationError, OpenProjectClient, + OpenProjectError, } from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; import { @@ -656,3 +657,75 @@ describe("Wikis Service", () => { expect(searchResultsB[0]!.title).toBe("Tenant B Page"); }); }); + +describe("Wiki Fuzzy Search", () => { + function wikiFixtureClient() { + const pages: Record = { + "/api/v3/wiki_pages/1": { + _type: "WikiPage", + id: 1, + title: "Onboarding Guide", + text: { raw: "New engineers should request VPN access on their first day." }, + _links: { project: { href: "/api/v3/projects/1", title: "Demo project" } }, + }, + "/api/v3/wiki_pages/2": { + _type: "WikiPage", + id: 2, + title: "Deployment Runbook", + text: { raw: "Roll back with the previous image tag if smoke tests fail." }, + _links: { project: { href: "/api/v3/projects/1", title: "Demo project" } }, + }, + }; + + return { + getCacheKey: () => `test-${Math.random()}`, + baseUrl: "http://localhost", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", _embedded: { elements: [] } }; + } + if (path.endsWith("/attachments")) { + return { _type: "Collection", _embedded: { elements: [] } }; + } + if (pages[path]) { + return pages[path]; + } + throw new OpenProjectError("not found", { statusCode: 404 }); + }, + } as unknown as OpenProjectClient; + } + + test("retains page body text when normalizing", async () => { + const page = await getWikiPage(1, wikiFixtureClient()); + expect(page.text).toContain("VPN access"); + }); + + test("finds a page by body text, not just title", async () => { + const results = await searchWikiPages({ query: "vpn access" }, wikiFixtureClient()); + expect(results[0]!.id).toBe(1); + expect(results[0]!.matchedFields).toContain("text"); + }); + + test("ranks a title match above a body match", async () => { + const results = await searchWikiPages({ query: "deployment" }, wikiFixtureClient()); + expect(results[0]!.id).toBe(2); + }); + + test("tolerates a typo in the query", async () => { + const results = await searchWikiPages({ query: "onboardng" }, wikiFixtureClient()); + expect(results[0]!.id).toBe(1); + }); + + test("returns a snippet centred on the body match", async () => { + const results = await searchWikiPages({ query: "smoke tests" }, wikiFixtureClient()); + expect(results[0]!.snippet).toContain("smoke tests"); + }); + + test("exact mode finds nothing for a typo", async () => { + const results = await searchWikiPages( + { query: "onboardng", matchMode: "exact" }, + wikiFixtureClient() + ); + expect(results).toHaveLength(0); + }); +});