docs(search): say that the activities query filters as well as ranks

`query` on `openproject_list_work_package_activities` returns
`rankRecords(...)`, which drops everything below `DEFAULT_MIN_SCORE`, so
it filters as well as ranks. The schema description, README, and ADR-019
all said "rank". A model told "rank" passes a query to a list tool
expecting a reordered full history and then reasons over a silently
truncated timeline.

Behaviour is unchanged — filtering is the more useful behaviour and the
tests pin it. Only the wording is corrected, plus tests that keep the
wording from regressing. Also records the response-shape and pagination
changes from the preceding commits in ADR-019 and the README.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-24 09:27:33 +02:00
co-authored by Claude Opus 5
parent a8d3c23096
commit e76cfa750b
4 changed files with 87 additions and 8 deletions
+5 -3
View File
@@ -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
+20 -2
View File
@@ -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.
+6 -3
View File
@@ -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,
+56
View File
@@ -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]);
});
});