diff --git a/README.md b/README.md index 82f7726..bea679e 100644 --- a/README.md +++ b/README.md @@ -351,9 +351,11 @@ This launches a local web UI (typically at `http://localhost:5173`) where you ca `openproject_search_meetings`, `openproject_search_wiki_pages`, and `openproject_search_work_packages` rank results by relevance instead of returning a flat list. They accept `matchMode: "fuzzy" | "exact"` (default `"fuzzy"`): - **`fuzzy`** (default) tolerates typos, reordered words, and partial/natural-language phrasing (e.g. "budget aproval", "what did we decide about hiring"). Results are ranked and each one carries a `score` (0..1 relevance) and `matchedFields` (which fields matched, highest-scoring first); work package and wiki page results also carry a `snippet` excerpt. -- **`exact`** restores literal case-insensitive substring matching, byte for byte equivalent to this project's pre-fuzzy search behavior. +- **`exact`** restores literal case-insensitive substring matching, byte for byte equivalent to this project's pre-fuzzy search behavior. Exact mode still searches descriptions, comments, and agenda content — it changes *how* text is matched, not *what* is searched. -`openproject_list_work_package_activities` similarly accepts an optional `query`/`matchMode` pair to rank the activities it already fetched, at no extra API cost. `openproject_list_work_packages` is unchanged — it remains a plain filter tool (`subject` substring match only, no ranking), kept deliberately separate from the ranked `openproject_search_work_packages` tool. +On every search tool, `offset` is a **1-based page number**, not an item index: with `pageSize: 20`, `offset: 2` returns results 21-40. Ranked results are returned under `elements`. + +`openproject_list_work_package_activities` similarly accepts an optional `query`/`matchMode` pair, applied to the activities it already fetched at no extra API cost. Note that `query` **filters as well as ranks**: activities scoring below the relevance threshold are dropped, so the response is a relevant subset of the timeline rather than the full history reordered. Omit `query` to retrieve the complete activity list. `openproject_list_work_packages` is unchanged — it remains a plain filter tool (`subject` substring match only, no ranking), kept deliberately separate from the ranked `openproject_search_work_packages` tool. ### Projects - `openproject_list_projects`: List projects with pagination (`offset`, `pageSize`), sorting (`sortBy`), and filtering. @@ -362,7 +364,7 @@ This launches a local web UI (typically at `http://localhost:5173`) where you ca ### Work Packages & Activities - `openproject_list_work_packages`: Query work packages with high-level filters (`projectId`, `status`, `typeId`, `assigneeId`, `priorityId`, `subject`, `pageSize`, `offset`) or custom JSON filter expressions. - `openproject_get_work_package`: Retrieve detailed information for a specific work package by ID (`workPackageId: 38`), including description, type, status, priority, author, dates, parent, and children. -- `openproject_list_work_package_activities`: Retrieve timeline history, field change logs, and discussions for a work package (`workPackageId: 38`), with optional `onlyComments` filtering and optional `query`/`matchMode` relevance ranking. +- `openproject_list_work_package_activities`: Retrieve timeline history, field change logs, and discussions for a work package (`workPackageId: 38`), with optional `onlyComments` filtering and an optional `query`/`matchMode` pair that filters the timeline to relevant entries and ranks them (non-matching activities are omitted). - `openproject_search_work_packages`: Ranked fuzzy search across work package subjects, descriptions, and comments (`query: "budget aproval"`, optional `projectId`, `status`, `matchMode`, `offset`, `pageSize`). Returns `score`, `matchedFields`, and a `snippet` per result, plus `degraded`/`enrichmentFailures` if some deep content couldn't be fetched. ### Meetings diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0a3a67a..8a4b477 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -525,8 +525,11 @@ only, and work package search matched subjects only. 6. **No standalone activities search tool**, because OpenProject exposes activities only per work package. Comment text enters the work package deep phase instead. `openproject_list_work_package_activities` gained an optional - `query`/`matchMode` pair that ranks the activities it already fetched - client-side, at no extra API cost. + `query`/`matchMode` pair that filters and ranks the activities it already + fetched client-side, at no extra API cost. It applies the same + `DEFAULT_MIN_SCORE` gate as every other ranked surface, so a query narrows + the returned timeline to relevant entries rather than reordering the full + history; omitting `query` returns the complete list. ### Consequences @@ -547,6 +550,21 @@ only, and work package search matched subjects only. section 6.2). - Scoring constants are tuning parameters. `tests/search/recall.test.ts` is the acceptance test that governs them. +- Search response envelopes carry ranked results under `elements` only. The + duplicate `items` alias that non-search list functions still return is + omitted from `MeetingSearchPage` and `WorkPackageSearchPage`, because the + tool layer JSON-stringifies the whole envelope and shipping the same array + twice doubled the token cost of every search response. +- `offset` on both search pages is a 1-based page number, matching the tool + schemas and the OpenProject API. This corrected `openproject_search_meetings`, + which previously sliced from item index `offset - 1` and returned a page + overlapping the previous one by all but a single result. +- The candidate union for work package search runs both legs in every match + mode. Restricting exact mode to the server-side `subject ~` filter made + description- and comment-only matches unreachable precisely for the literal + strings (error codes, ticket refs, commit SHAs) that exact mode exists for. + Both legs page the candidate window in batches of 100 up to the 250 cap, + since OpenProject enforces a server-side maximum page size. diff --git a/src/tools/work-packages.ts b/src/tools/work-packages.ts index 9f021e5..4dee114 100644 --- a/src/tools/work-packages.ts +++ b/src/tools/work-packages.ts @@ -185,8 +185,11 @@ export const listWorkPackageActivitiesShape = { .string() .optional() .describe( - "Optional search text to rank activities by relevance. Matched against " + - "comment text and change details; tolerates typos." + "Optional search text. FILTERS the activity list down to relevant " + + "entries and ranks them by relevance — activities that do not match " + + "are omitted, so the returned timeline is not the complete history. " + + "Omit this parameter to get the full, unfiltered list. Matched " + + "against comment text and change details; tolerates typos." ), matchMode: z .enum(["fuzzy", "exact"]) @@ -227,7 +230,7 @@ export const listWorkPackageActivitiesTool: ToolDefinition< > = { name: "openproject_list_work_package_activities", description: - "Retrieve timeline activities and comments for a work package. Can optionally filter to only include user comments.", + "Retrieve timeline activities and comments for a work package. Can optionally filter to only include user comments. Supplying 'query' filters the timeline to relevant entries and ranks them, rather than reordering the full history.", parameters: listWorkPackageActivitiesShape, readOnly: true, execute: handleListWorkPackageActivities, diff --git a/tests/work-package-activities.test.ts b/tests/work-package-activities.test.ts index a5bdab4..f8c4bf8 100644 --- a/tests/work-package-activities.test.ts +++ b/tests/work-package-activities.test.ts @@ -2,6 +2,10 @@ import { describe, expect, test } from "bun:test"; import { OpenProjectClient } from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; import { listWorkPackageActivities } from "../src/services/work-packages.ts"; +import { + listWorkPackageActivitiesShape, + listWorkPackageActivitiesTool, +} from "../src/tools/work-packages.ts"; describe("Work Package Activities Service", () => { test("parses activities and separates comments from field changes", async () => { @@ -204,3 +208,55 @@ describe("Activity Ranking", () => { expect(result).toHaveLength(0); }); }); + +describe("Work Package Activities query documentation", () => { + /** + * `query` does not merely reorder the timeline — rankRecords drops + * everything below DEFAULT_MIN_SCORE. A model told the parameter "ranks" + * will reason over a silently truncated history, so the schema and the tool + * description must both say that it filters. + */ + test("the query parameter is documented as filtering, not only ranking", () => { + const description = listWorkPackageActivitiesShape.query.description ?? ""; + expect(description.toLowerCase()).toContain("filter"); + expect(description.toLowerCase()).toMatch(/not the complete history|are omitted/); + }); + + test("the tool description warns that a query narrows the timeline", () => { + expect(listWorkPackageActivitiesTool.description.toLowerCase()).toContain("filters"); + }); + + test("a query really does drop non-matching activities", async () => { + const client = { + get: async () => ({ + _type: "Collection", + _embedded: { + elements: [ + { + id: 1, + version: 1, + createdAt: "2026-09-10T00:00:00Z", + comment: { raw: "Renewed the expired TLS certificate" }, + _links: { user: { href: "/api/v3/users/4", title: "Admin" } }, + }, + { + id: 2, + version: 2, + createdAt: "2026-09-11T00:00:00Z", + comment: { raw: "Merged the dependency bump" }, + _links: { user: { href: "/api/v3/users/5", title: "Dev" } }, + }, + ], + }, + }), + } as unknown as OpenProjectClient; + + const all = await listWorkPackageActivities({ workPackageId: 1 }, client); + const filtered = await listWorkPackageActivities( + { workPackageId: 1, query: "TLS certificate" }, + client + ); + expect(all).toHaveLength(2); + expect(filtered.map((a) => a.id)).toEqual([1]); + }); +});