feat(activities): rank work package activities by relevance
Adds optional query/matchMode to listWorkPackageActivities, ranking the already-fetched activity list client-side via rankRecords (no extra API calls). Exposes the same fields on the openproject_list_work_package_activities tool. When no query is supplied, every activity is returned exactly as before, and onlyComments continues to filter independently. Also fixes a defect in the brief's exact-mode test fixture: the specified typo query "certificat" is a literal prefix substring of "certificate", so byte-for-byte exact containment matching (scoreExact in src/search/rank.ts) finds it regardless of typo tolerance, making the test unable to pass against a correct implementation. Swapped in "certificatee" (not a substring of "certificate") to preserve the test's intent without touching the assertion. Updated one pre-existing test-tools.test.ts call site to supply matchMode, now required by the extended args type. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a28067f61e
commit
8f29eb651d
@@ -19,7 +19,7 @@ import type {
|
|||||||
} from "../client/types.ts";
|
} from "../client/types.ts";
|
||||||
import { resolveClient, resolveProjectId } from "./helper.ts";
|
import { resolveClient, resolveProjectId } from "./helper.ts";
|
||||||
import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts";
|
import { isFatalSearchError, searchPipeline } from "../search/pipeline.ts";
|
||||||
import type { FieldSpec, MatchMode } from "../search/rank.ts";
|
import { rankRecords, type FieldSpec, type MatchMode } from "../search/rank.ts";
|
||||||
|
|
||||||
export interface ListWorkPackagesParams extends WorkPackageFilterParams {
|
export interface ListWorkPackagesParams extends WorkPackageFilterParams {
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -249,6 +249,9 @@ export interface WorkPackageActivity {
|
|||||||
export interface ListWorkPackageActivitiesOptions {
|
export interface ListWorkPackageActivitiesOptions {
|
||||||
workPackageId: number;
|
workPackageId: number;
|
||||||
onlyComments?: boolean;
|
onlyComments?: boolean;
|
||||||
|
/** Ranks the fetched activities client-side. No extra API calls. */
|
||||||
|
query?: string;
|
||||||
|
matchMode?: MatchMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -307,6 +310,22 @@ export async function listWorkPackageActivities(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities;
|
const query = options.query?.trim() ?? "";
|
||||||
|
if (query.length === 0) {
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: FieldSpec<WorkPackageActivity>[] = [
|
||||||
|
{ name: "comment", weight: 3, extract: (activity) => activity.comment },
|
||||||
|
{
|
||||||
|
name: "details",
|
||||||
|
weight: 1,
|
||||||
|
extract: (activity) => activity.details.map((detail) => detail.raw),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return rankRecords(activities, query, fields, {
|
||||||
|
matchMode: options.matchMode ?? "fuzzy",
|
||||||
|
}).map((entry) => entry.record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,21 @@ export const listWorkPackageActivitiesShape = {
|
|||||||
.describe(
|
.describe(
|
||||||
"When true, filters out property change audits and returns only comments"
|
"When true, filters out property change audits and returns only comments"
|
||||||
),
|
),
|
||||||
|
query: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional search text to rank activities by relevance. Matched against " +
|
||||||
|
"comment text and change details; tolerates typos."
|
||||||
|
),
|
||||||
|
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 ListWorkPackageActivitiesArgs = z.infer<
|
export type ListWorkPackageActivitiesArgs = z.infer<
|
||||||
@@ -197,6 +212,8 @@ export async function handleListWorkPackageActivities(
|
|||||||
const result = await listWorkPackageActivities({
|
const result = await listWorkPackageActivities({
|
||||||
workPackageId: args.workPackageId,
|
workPackageId: args.workPackageId,
|
||||||
onlyComments: args.onlyComments,
|
onlyComments: args.onlyComments,
|
||||||
|
query: args.query,
|
||||||
|
matchMode: args.matchMode,
|
||||||
});
|
});
|
||||||
return formatToolSuccess(result);
|
return formatToolSuccess(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ describe("Work Package Tools", () => {
|
|||||||
handleListWorkPackageActivities({
|
handleListWorkPackageActivities({
|
||||||
workPackageId: 999,
|
workPackageId: 999,
|
||||||
onlyComments: false,
|
onlyComments: false,
|
||||||
|
matchMode: "fuzzy",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -138,3 +138,69 @@ describe("Work Package Activities Service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Activity Ranking", () => {
|
||||||
|
function activitiesClient() {
|
||||||
|
return {
|
||||||
|
get: async () => ({
|
||||||
|
_type: "Collection",
|
||||||
|
_embedded: {
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
version: 1,
|
||||||
|
createdAt: "2026-09-01T00:00:00Z",
|
||||||
|
comment: { raw: "Deployment blocked by the expired TLS certificate" },
|
||||||
|
_links: { user: { href: "/api/v3/users/4", title: "Admin" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
version: 2,
|
||||||
|
createdAt: "2026-09-02T00:00:00Z",
|
||||||
|
comment: { raw: "Merged the dependency bump" },
|
||||||
|
_links: { user: { href: "/api/v3/users/5", title: "Dev" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as unknown as OpenProjectClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("ranks activities by relevance when a query is supplied", async () => {
|
||||||
|
const result = await listWorkPackageActivities(
|
||||||
|
{ workPackageId: 1, query: "TLS certificate" },
|
||||||
|
activitiesClient()
|
||||||
|
);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tolerates a typo in the query", async () => {
|
||||||
|
const result = await listWorkPackageActivities(
|
||||||
|
{ workPackageId: 1, query: "certificat" },
|
||||||
|
activitiesClient()
|
||||||
|
);
|
||||||
|
expect(result[0]!.id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns every activity when no query is supplied", async () => {
|
||||||
|
const result = await listWorkPackageActivities({ workPackageId: 1 }, activitiesClient());
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exact mode finds nothing for a typo", async () => {
|
||||||
|
// Deliberately not "certificat" (the brief's original value): that string
|
||||||
|
// is a literal prefix substring of "certificate", so byte-for-byte exact
|
||||||
|
// containment matching (scoreExact in src/search/rank.ts, "reproducing
|
||||||
|
// the pre-fuzzy behaviour byte for byte") finds it regardless of typo
|
||||||
|
// tolerance, making the original fixture unable to exercise "exact mode
|
||||||
|
// rejects typos" against a correct implementation. "certificatee" (an
|
||||||
|
// extra trailing letter) is not a substring of "certificate", so it
|
||||||
|
// exercises the same intent without the collision.
|
||||||
|
const result = await listWorkPackageActivities(
|
||||||
|
{ workPackageId: 1, query: "certificatee", matchMode: "exact" },
|
||||||
|
activitiesClient()
|
||||||
|
);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user