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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2d44a229e0
commit
9dee458b1f
+47
-19
@@ -7,9 +7,10 @@ import {
|
|||||||
OpenProjectAuthenticationError,
|
OpenProjectAuthenticationError,
|
||||||
type OpenProjectClient,
|
type OpenProjectClient,
|
||||||
} from "../client/api-client.ts";
|
} 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 type { HalCollection, HalLink, HalResource } from "../client/types.ts";
|
||||||
import { resolveClient, resolveProjectId } from "./helper.ts";
|
import { resolveClient, resolveProjectId } from "./helper.ts";
|
||||||
|
import { rankRecords, type FieldSpec, type MatchMode } from "../search/rank.ts";
|
||||||
|
|
||||||
export interface WikiPageAttachment {
|
export interface WikiPageAttachment {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,6 +23,8 @@ export interface WikiPageAttachment {
|
|||||||
export interface WikiPageDetail {
|
export interface WikiPageDetail {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Raw markdown body from the formattable `text` field. */
|
||||||
|
text?: string;
|
||||||
project: { id: number; identifier?: string; name?: string };
|
project: { id: number; identifier?: string; name?: string };
|
||||||
attachments: WikiPageAttachment[];
|
attachments: WikiPageAttachment[];
|
||||||
}
|
}
|
||||||
@@ -61,8 +64,16 @@ export interface SearchWikiPagesParams {
|
|||||||
projectId?: string | number;
|
projectId?: string | number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
refreshCache?: boolean;
|
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
|
// In-memory cache for discovered wiki pages keyed by client cache key
|
||||||
const wikiPagesCache = new Map<string, Map<number, WikiPageDetail>>();
|
const wikiPagesCache = new Map<string, Map<number, WikiPageDetail>>();
|
||||||
const discoveredCacheKeys = new Set<string>();
|
const discoveredCacheKeys = new Set<string>();
|
||||||
@@ -119,10 +130,12 @@ export function normalizeWikiPageDetail(
|
|||||||
|
|
||||||
const id = resource.id ?? extractIdFromHref(links.self?.href) ?? 0;
|
const id = resource.id ?? extractIdFromHref(links.self?.href) ?? 0;
|
||||||
const title = String(resource.title ?? "");
|
const title = String(resource.title ?? "");
|
||||||
|
const text = extractRawText(resource.text);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
...(text ? { text } : {}),
|
||||||
project,
|
project,
|
||||||
attachments,
|
attachments,
|
||||||
};
|
};
|
||||||
@@ -257,7 +270,7 @@ export async function listWikiPageLinks(
|
|||||||
export async function searchWikiPages(
|
export async function searchWikiPages(
|
||||||
params?: SearchWikiPagesParams,
|
params?: SearchWikiPagesParams,
|
||||||
client?: OpenProjectClient
|
client?: OpenProjectClient
|
||||||
): Promise<WikiPageSummary[]> {
|
): Promise<WikiPageSearchResult[]> {
|
||||||
const opClient = resolveClient(client);
|
const opClient = resolveClient(client);
|
||||||
const cacheKey = opClient.getCacheKey?.() ?? (opClient.baseUrl || "default");
|
const cacheKey = opClient.getCacheKey?.() ?? (opClient.baseUrl || "default");
|
||||||
|
|
||||||
@@ -364,24 +377,39 @@ export async function searchWikiPages(
|
|||||||
targetProjectId = await resolveProjectId(params.projectId, opClient);
|
targetProjectId = await resolveProjectId(params.projectId, opClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Filter cached pages
|
// 4. Scope cached pages to the target project
|
||||||
const allPages = Array.from(cacheMap.values());
|
const allPages = Array.from(cacheMap.values()).filter(
|
||||||
const querySubstring = params?.query?.trim().toLowerCase();
|
(page) => targetProjectId === undefined || page.project.id === targetProjectId
|
||||||
|
);
|
||||||
|
|
||||||
let filtered = allPages.filter((page) => {
|
// 5. Rank by relevance
|
||||||
if (targetProjectId !== undefined && page.project.id !== targetProjectId) {
|
const query = params?.query?.trim() ?? "";
|
||||||
return false;
|
if (query.length === 0) {
|
||||||
}
|
const limited =
|
||||||
if (querySubstring && !page.title.toLowerCase().includes(querySubstring)) {
|
params?.limit !== undefined && params.limit > 0
|
||||||
return false;
|
? allPages.slice(0, params.limit)
|
||||||
}
|
: allPages;
|
||||||
return true;
|
return limited.map((page) => ({
|
||||||
});
|
...toWikiPageSummary(page),
|
||||||
|
score: 0,
|
||||||
// 5. Apply limit
|
matchedFields: [],
|
||||||
if (params?.limit !== undefined && params.limit > 0) {
|
}));
|
||||||
filtered = filtered.slice(0, params.limit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
@@ -46,7 +46,10 @@ export const searchWikiPagesShape = {
|
|||||||
query: z
|
query: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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
|
projectId: z
|
||||||
.union([z.number().int().positive(), z.string().min(1)])
|
.union([z.number().int().positive(), z.string().min(1)])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -64,6 +67,14 @@ export const searchWikiPagesShape = {
|
|||||||
.optional()
|
.optional()
|
||||||
.default(false)
|
.default(false)
|
||||||
.describe("Force cache refresh"),
|
.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>>;
|
export type SearchWikiPagesArgs = z.input<z.ZodObject<typeof searchWikiPagesShape>>;
|
||||||
@@ -140,7 +151,9 @@ export const searchWikiPagesTool: ToolDefinition<
|
|||||||
SearchWikiPagesArgs
|
SearchWikiPagesArgs
|
||||||
> = {
|
> = {
|
||||||
name: "openproject_search_wiki_pages",
|
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,
|
parameters: searchWikiPagesShape,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
execute: handleSearchWikiPages,
|
execute: handleSearchWikiPages,
|
||||||
|
|||||||
+2
-1
@@ -1464,7 +1464,8 @@ describe("Wikis Tools", () => {
|
|||||||
|
|
||||||
expect(searchWikiPagesTool.name).toBe("openproject_search_wiki_pages");
|
expect(searchWikiPagesTool.name).toBe("openproject_search_wiki_pages");
|
||||||
expect(searchWikiPagesTool.description).toBe(
|
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);
|
expect(searchWikiPagesTool.readOnly).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test, beforeEach } from "bun:test";
|
|||||||
import {
|
import {
|
||||||
OpenProjectAuthenticationError,
|
OpenProjectAuthenticationError,
|
||||||
OpenProjectClient,
|
OpenProjectClient,
|
||||||
|
OpenProjectError,
|
||||||
} from "../src/client/api-client.ts";
|
} from "../src/client/api-client.ts";
|
||||||
import { runWithContext, type RequestContext } from "../src/context.ts";
|
import { runWithContext, type RequestContext } from "../src/context.ts";
|
||||||
import {
|
import {
|
||||||
@@ -656,3 +657,75 @@ describe("Wikis Service", () => {
|
|||||||
expect(searchResultsB[0]!.title).toBe("Tenant B Page");
|
expect(searchResultsB[0]!.title).toBe("Tenant B Page");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Wiki Fuzzy Search", () => {
|
||||||
|
function wikiFixtureClient() {
|
||||||
|
const pages: Record<string, unknown> = {
|
||||||
|
"/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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user