feat(wikis): search page body text with fuzzy relevance ranking

Retain the page body from the formattable text field (already fetched
per-page, zero extra API calls) and rank title/text matches with
rankRecords instead of exact substring on title alone. searchWikiPages
now returns WikiPageSearchResult[] with score, snippet, and
matchedFields; empty-query behavior (project scoping + limit, no
ranking) is preserved. Adds a matchMode tool parameter defaulting to
fuzzy.

Updates the pre-existing tool-metadata assertion in tests/tools.test.ts
to match the new search_wiki_pages description introduced here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-18 07:48:29 +02:00
1 parent 2d44a229e0
commit 9dee458b1f
4 files changed
+137 -22

No files matched your search

+47 -19
View File
@@ -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<string, Map<number, WikiPageDetail>>();
const discoveredCacheKeys = new Set<string>();
@@ -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<WikiPageSummary[]> {
): Promise<WikiPageSearchResult[]> {
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<WikiPageDetail>[] = [
{ 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),
}));
}
+15 -2
View File
@@ -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<z.ZodObject<typeof searchWikiPagesShape>>;
@@ -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,