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:
co-authored by
Claude Opus 5
parent
a8d3c23096
commit
e76cfa750b
@@ -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
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user