From 97f115e6419d037966d86e22a2c32be51fc11621 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 24 Sep 2026 09:05:57 +0200 Subject: [PATCH] docs: record ADR-019 and document fuzzy search behaviour Documents the fuzzy-search-by-default change shipped across tasks 1-10: appends ADR-019 (fuzzy default, hand-rolled zero-dependency scorer, why fuse.js/fastest-levenshtein were rejected), describes the src/search/ pure computation unit in ARCHITECTURE.md, updates README.md's tool table/count (18 -> 19) and adds a Search behaviour section, and updates TODO.md with the completed milestone, two accepted limitations, and three tracked follow-ups. No source or test files were changed; bun test (349 pass) and bun run typecheck remain unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 20 ++++++++++--- docs/ARCHITECTURE.md | 49 +++++++++++++++++++++++-------- docs/DECISIONS.md | 69 ++++++++++++++++++++++++++++++++++++++++++++ docs/TODO.md | 26 +++++++++++++++++ 4 files changed, 148 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5a46938..82f7726 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,16 @@ This launches a local web UI (typically at `http://localhost:5173`) where you ca ## Available MCP Tools -`openproject-mcp` currently exposes 18 tools: +`openproject-mcp` currently exposes 19 tools. + +### Search behaviour + +`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. + +`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. ### Projects - `openproject_list_projects`: List projects with pagination (`offset`, `pageSize`), sorting (`sortBy`), and filtering. @@ -353,16 +362,17 @@ 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. +- `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_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 - `openproject_list_meetings`: List and filter meetings visible to the user by project (`projectId`) or time context (`time: "upcoming"` / `"past"`), with pagination (`offset`, `pageSize`). - `openproject_get_meeting`: Retrieve detailed meeting information by ID (`id: 2`), including structured agenda items, sections, notes, outcomes, and participants. -- `openproject_search_meetings`: Deep search across meeting titles, locations, and agenda item notes by keyword (`query: "planning"`). +- `openproject_search_meetings`: Ranked fuzzy search across meeting titles, locations, project and author names, agenda item titles and notes, outcomes, and participant names (`query: "planning"`, optional `projectId`, `matchMode`, `offset`, `pageSize`). Returns `score`, `matchedFields`, and matched agenda item excerpts, plus `degraded`/`enrichmentFailures` if some deep content couldn't be fetched. ### Wikis - `openproject_get_wiki_page`: Retrieve wiki page metadata, project, and attachments by numeric ID (`id: 1`). -- `openproject_search_wiki_pages`: Discover and search wiki pages matching keywords or project with smart caching discovery (`query: "architecture"`, `projectId: "demo-project"`). +- `openproject_search_wiki_pages`: Ranked fuzzy search across wiki page titles and page body text with smart caching discovery (`query: "architecture"`, `projectId: "demo-project"`, optional `matchMode`). Returns `score`, `matchedFields`, and a `snippet` per result. - `openproject_list_wiki_page_links`: List links connecting work packages to wiki pages (`workPackageId: 38`, `offset`, `pageSize`). ### Saved Queries & Views @@ -393,6 +403,7 @@ Once connected in Claude Desktop, Cursor, or your agent of choice, you can ask q - *"What work packages are assigned to me, and what are their priorities?"* - *"Get details for work package #38 including its child tasks."* - *"Show all comments and discussion history on work package #38."* +- *"Search work packages for anything about budget approval, even if it's misspelled."* - *"List upcoming meetings and show the agenda items for our weekly planning meeting."* - *"Search our meeting notes to see if anyone discussed the new architecture."* - *"Find wiki pages discussing 'architecture' or 'setup' and list their attachments."* @@ -510,6 +521,7 @@ PORT=3000 bun run src/index.ts - **Phase 1 (Completed)**: Read-only browsing tools for projects, work packages, queries, taxonomies, and OpenAPI introspection. - **Phase 4 (Completed)**: Remote HTTP/SSE transport (`Bun.serve`) with multi-tenant per-session credential scoping and Docker Compose deployment. - **Phase 3 Extension (Completed)**: Read-only collaboration tools across Meetings, Wikis, and Work Package Activities (18 tools total). +- **Phase 3.5 (Completed)**: Fuzzy search by default across all search tools with widened content coverage and a new `openproject_search_work_packages` tool (19 tools total). See [ADR-019](docs/DECISIONS.md). - **Phase 2 (Upcoming)**: Mutating operations (create/update work packages, add comments, log time). - **Phase 3 (Future)**: Binary attachment downloading and resource streaming. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ecd52ef..a57d0b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,12 +64,12 @@ The system is organized into four modular layers: ### 2.2. Domain Services / Tool Providers Domain services map MCP tool calls to concrete business logic and OpenProject API operations: - **Projects Service**: Listing accessible projects, retrieving project details, and fetching project-level schemas. -- **Work Packages Service**: Listing work packages with filters, searching by text or ID, retrieving work package details, inspecting relations, and browsing work package timeline activities and comments. +- **Work Packages Service**: Listing work packages with filters, retrieving work package details, inspecting relations, browsing work package timeline activities and comments (optionally ranked by relevance), and ranked fuzzy search across subjects, descriptions, and comments via the two-phase pipeline. - **Queries Service**: Listing saved queries (views) configured in OpenProject and executing them. - **Metadata & Taxonomies Service**: Retrieving statuses, work package types, priorities, categories, versions, and users to enable LLMs to construct valid queries and interpret responses. - **OpenAPI Introspection Service**: Dynamic API v3 schema introspection, endpoint parameter schemas, tag discovery, and model definitions with in-memory caching. -- **Meetings Service**: Listing and filtering meetings (upcoming/past), retrieving meeting details with structured agenda items, sections, and outcomes, and deep keyword search across meeting titles, locations, and agenda item notes. -- **Wikis Service**: Retrieving wiki pages with embedded attachments, smart discovery search across wiki pages using cached harvesting and consecutive 404 cutoff, and listing wiki page links to work packages. +- **Meetings Service**: Listing and filtering meetings (upcoming/past), retrieving meeting details with structured agenda items, sections, and outcomes, and ranked fuzzy search across meeting titles, locations, project and author names, agenda item titles and notes, outcomes, and participant names. +- **Wikis Service**: Retrieving wiki pages with embedded attachments, ranked fuzzy search across page titles and body text using cached discovery harvesting and a consecutive 404 cutoff, and listing wiki page links to work packages. ### 2.3. OpenProject Client Layer - **HTTP Transport**: Handles HTTPS communication against the configured `OPENPROJECT_BASE_URL`. @@ -87,7 +87,20 @@ Domain services map MCP tool calls to concrete business logic and OpenProject AP - `HOST`: Server bind address (or `--host `, default: `0.0.0.0`). - Fails fast with actionable setup advice if required credentials are missing or invalid in stdio mode. -### 2.5. Read-Only Execution Mode +### 2.5. Fuzzy Search Unit (`src/search/`) +A pure computation unit sitting between the tools layer and the domain services layer. It performs no I/O and holds no module-level mutable state — every function takes its input as arguments and returns a value, so it cannot leak state across requests or tenants. + +Domain services declare *what* to search (which fields, at what relative importance, over which records); this unit decides *how well* each record matches. The split keeps the fuzzy-matching logic in one place, hand-written and unit-tested, rather than duplicated per service. + +- **`tokenize.ts`**: Normalizes text (NFKD diacritic stripping, lowercasing) and splits it into tokens, preserving each token's offset in the original (un-normalized) string so later stages can extract snippets. Query tokenization additionally strips English stopwords (falling back to the unfiltered tokens if that would remove everything), because coverage-based scoring would otherwise drive natural-language questions ("what did we decide about hiring") toward zero. +- **`score.ts`**: A bounded Levenshtein distance with an early exit (the common case is a non-match, so cheap rejection matters more than a tight true distance), and `scoreField`, which turns a query's tokens and a field's text into a 0..1 score combining token coverage, edit-distance tolerance for typos, and a bonus for verbatim phrase matches. +- **`snippet.ts`**: Extracts a short excerpt centered on the matched offsets, for showing the caller *where* a hit occurred in a long field (a wiki body, a comment). +- **`rank.ts`**: `rankRecords(records, query, fields: FieldSpec[], options)` — the generic ranking entry point. A record's score is the best weighted field score (`max over fields of fieldScore * weight / maxDeclaredWeight`), not a sum, so a strong hit on one field isn't diluted by weak hits elsewhere. `matchMode: "exact"` bypasses fuzzy scoring entirely and reproduces the previous case-insensitive substring-containment behavior byte for byte, including its lack of a minimum-score gate. Ties are broken by ascending ID for deterministic ordering. +- **`pipeline.ts`**: `searchPipeline` orchestrates a two-phase search: phase 1 ranks candidates on data already returned by a list endpoint (no extra API calls); phase 2 enriches a bounded window (default 25, see `DEFAULT_ENRICH_LIMIT`) — the top candidates by shallow score, backfilled by recency for records whose only match is in content that hasn't been fetched yet — at a bounded concurrency (default 8, `DEFAULT_CONCURRENCY`) via `mapWithConcurrency`. A record's final score is `max(shallow, deep)`. A non-fatal enrichment failure (e.g. a transient 500 on one candidate) is recorded and does not fail the whole search; a fatal one (401/403/429, via `isFatalSearchError`) propagates, so an expired token surfaces as an authentication error rather than a silent "no results found". + +Consumers: `searchMeetings` and `searchWorkPackages` (in `src/services/`) use the full two-phase `searchPipeline`; `searchWikiPages` and the ranked view of `listWorkPackageActivities` call `rankRecords` directly over data they already hold, since neither needs a second enrichment round-trip. + +### 2.6. Read-Only Execution Mode The server supports a dedicated read-only operating mode designed for auditing, reporting, and exploratory agent workflows: - **Tool Filtering**: When read-only mode is active, only read/browse/query tools are registered in the MCP tool registry. Mutating tools (creating work packages, updating attributes, logging time) are completely excluded from the tool manifest exposed to the LLM. - **Defense-in-Depth Guard**: In addition to tool manifest omission, the internal tool router validates whether an operation is a mutating action. Any write attempt is immediately rejected with a structured error (`SERVER_READ_ONLY: Server is operating in read-only mode. Write operations are disabled.`). @@ -141,7 +154,7 @@ The server is architected from the ground up to support concurrent, multi-user o --- -## 4. Tool Specifications (Browse & Query Scope - 18 Tools) +## 4. Tool Specifications (Browse & Query Scope - 19 Tools) | MCP Tool Name | Description | Key Parameters | | :--- | :--- | :--- | @@ -149,7 +162,8 @@ The server is architected from the ground up to support concurrent, multi-user o | `openproject_get_project` | Get details of a single project by ID or identifier | `projectId` (number or string identifier) | | `openproject_list_work_packages` | Browse work packages with filtering and pagination | `projectId`, `status`, `type`, `pageSize`, `offset`, `filters` | | `openproject_get_work_package` | Get full details of a specific work package | `workPackageId` (number) | -| `openproject_list_work_package_activities` | Retrieve timeline activities and comments for a work package | `workPackageId` (number), `onlyComments` (boolean) | +| `openproject_list_work_package_activities` | Retrieve timeline activities and comments for a work package, optionally ranked by relevance | `workPackageId` (number), `onlyComments` (boolean), `query`, `matchMode` | +| `openproject_search_work_packages` | Ranked fuzzy search across work package subjects, descriptions, and comments | `query` (string), `projectId`, `status`, `matchMode`, `offset`, `pageSize` | | `openproject_list_queries` | List saved project or global queries (views) | `projectId`, `pageSize`, `offset` | | `openproject_get_query` | Retrieve details and results of a saved query | `queryId` (number) | | `openproject_list_types` | List all available work package types (Task, Bug, Milestone, etc.) | None | @@ -159,11 +173,13 @@ The server is architected from the ground up to support concurrent, multi-user o | `openproject_get_openapi_spec` | Retrieve OpenProject API v3 OpenAPI specification for schema introspection | `summary`, `path`, `tag`, `schema` | | `openproject_list_meetings` | List and filter meetings visible to the user | `projectId`, `time`, `offset`, `pageSize` | | `openproject_get_meeting` | Retrieve detailed meeting information including agenda items, sections, notes, and participants | `id` (number), `includeAgendaItems` (boolean) | -| `openproject_search_meetings` | Search across meetings and agenda items by keywords | `query` (string), `projectId`, `offset`, `pageSize` | +| `openproject_search_meetings` | Ranked fuzzy search across meetings and agenda items by keywords | `query` (string), `projectId`, `offset`, `pageSize`, `matchMode` | | `openproject_get_wiki_page` | Retrieve wiki page metadata, project, and attachments by numeric ID | `id` (number) | -| `openproject_search_wiki_pages` | Discover and search wiki pages matching keywords or project | `query`, `projectId`, `limit`, `refreshCache` | +| `openproject_search_wiki_pages` | Ranked fuzzy search across wiki page titles and body text | `query`, `projectId`, `limit`, `refreshCache`, `matchMode` | | `openproject_list_wiki_page_links` | List links connecting work packages to wiki pages | `workPackageId`, `offset`, `pageSize` | +All three dedicated search tools (`openproject_search_meetings`, `openproject_search_wiki_pages`, `openproject_search_work_packages`) default to `matchMode: "fuzzy"` and return each result's relevance `score` and `matchedFields`; passing `matchMode: "exact"` restores literal case-insensitive substring matching. See section 2.5 and ADR-019. + --- ## 5. OpenProject API v3 Specifics @@ -230,15 +246,22 @@ openproject-mcp/ │ │ └── types.ts # OpenProject API type definitions │ ├── config/ │ │ └── index.ts # Configuration loader and Zod schema +│ ├── search/ # Pure fuzzy-search unit (no I/O, no module-level state) +│ │ ├── index.ts # Barrel export +│ │ ├── tokenize.ts # Normalization, tokenization, stopword-aware query tokens +│ │ ├── score.ts # Bounded Levenshtein distance & field scoring +│ │ ├── snippet.ts # Match-centered excerpt extraction +│ │ ├── rank.ts # Generic FieldSpec record ranking (fuzzy & exact modes) +│ │ └── pipeline.ts # Two-phase orchestration with bounded concurrency │ ├── services/ │ │ ├── helper.ts # Client and project resolution helpers │ │ ├── projects.ts # Projects domain service -│ │ ├── work-packages.ts # Work packages & activities domain service +│ │ ├── work-packages.ts # Work packages, activities & ranked search domain service │ │ ├── queries.ts # Queries domain service │ │ ├── metadata.ts # Types, statuses, priorities, users │ │ ├── openapi.ts # OpenAPI specification discovery & caching -│ │ ├── meetings.ts # Meetings domain service & search -│ │ └── wikis.ts # Wikis domain service & discovery cache +│ │ ├── meetings.ts # Meetings domain service & ranked search +│ │ └── wikis.ts # Wikis domain service, discovery cache & ranked search │ └── tools/ │ ├── common.ts # Common schemas and error formatters │ ├── index.ts # Tool registration and execution wrapper @@ -246,11 +269,12 @@ openproject-mcp/ │ ├── openapi.ts # MCP tool definition for OpenAPI introspection │ ├── projects.ts # MCP tool definitions for projects │ ├── queries.ts # MCP tool definitions for saved queries -│ ├── work-packages.ts # MCP tool definitions for work packages & activities +│ ├── work-packages.ts # MCP tool definitions for work packages, activities & search │ ├── meetings.ts # MCP tool definitions for meetings │ └── wikis.ts # MCP tool definitions for wikis ├── tests/ │ ├── fixtures/ # HAL+JSON mock fixtures +│ ├── search/ # Unit tests for the src/search/ pure functions & recall suite │ ├── client.test.ts # OpenProject client unit tests │ ├── config.test.ts # Configuration loader unit tests │ ├── docker.test.ts # Docker packaging and compose tests @@ -277,5 +301,6 @@ openproject-mcp/ - **Phase 1 (Completed)**: Read/browse capability for projects, work packages, queries, taxonomies, and OpenAPI introspection. - **Phase 4 (Completed)**: Hosted remote MCP server (HTTP/SSE transport via `Bun.serve`) with multi-tenant per-session credential scoping and Docker Compose deployment. - **Phase 3 Extension (Completed)**: Read-only collaboration tools across Meetings (listing, detail inspection, deep keyword search across agenda notes), Wikis (page retrieval, smart discovery search, and links), and Work Package Activities (history and comments filtering). Total catalog: 18 tools. +- **Phase 3.5 (Completed)**: Fuzzy search by default across all search tools, via a hand-rolled zero-dependency scorer in `src/search/` (see section 2.5 and ADR-019). Widened content coverage (wiki body text, work package descriptions and comments, meeting participants and project names) and added `openproject_search_work_packages`. Total catalog: 19 tools. - **Phase 2 (Upcoming)**: Mutating operations (create/update work packages, add comments, log time). - **Phase 3 (Future)**: Attachment binary download resources. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 66d93b5..0a3a67a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -479,5 +479,74 @@ Furthermore, all new capabilities must strictly adhere to the project's read-onl - OpenProject API lack of a native wiki collection endpoint requires heuristic discovery probing. - Deep meeting search requires additional sub-requests for agenda items on candidate meetings, bounded by pagination limits. +--- + +## ADR-019: Fuzzy Search by Default with a Hand-Rolled Zero-Dependency Scorer + +### Status + +Accepted + +### Context + +Every search surface matched with case-insensitive exact substring containment. +An LLM asking for "budget aproval", "approval of the budget", or "what did we +decide about hiring" received zero results even when the information was +present, and then reported to the user that it did not exist. Search also +covered far less content than callers assumed: wiki search matched page titles +only, and work package search matched subjects only. + +### Decision + +1. **Fuzzy matching is the default** across all search tools, with an + optional `matchMode: "fuzzy" | "exact"` parameter. Exact mode reproduces + the previous behaviour byte for byte, which lets the pre-existing test + suites serve as a regression harness. +2. **A hand-rolled scorer in `src/search/`**, adding no runtime dependencies. + `fuse.js` was rejected because its Bitap implementation caps patterns at 32 + characters and targets short fields in modest lists — the opposite of the + wiki-body and comment matching this change requires. Running two scoring + systems to cover both cases was worse than owning ~200 lines of pure, + synchronous, trivially testable code. `fastest-levenshtein` was rejected + because the distance function is roughly 30 lines with the early-exit band + we need, and fetching, not distance computation, dominates the time. +3. **A bounded two-phase pipeline.** Phase 1 ranks on data already returned by + list endpoints; phase 2 enriches only the top 25 candidates at a + concurrency of 8. This replaces an unbounded `Promise.all` over up to 250 + candidates in the previous meeting search. +4. **Content coverage widened** to wiki page bodies, work package descriptions + and comments, and meeting participants and project names. +5. **A new `openproject_search_work_packages` tool**, taking the registry from + 18 to 19 tools. `searchWorkPackages` previously existed as a service helper + that no tool invoked, so ranked work package search would otherwise have been + unreachable. `openproject_list_work_packages` keeps its server-side + `subject ~` filter unchanged, keeping "filter" and "rank" as separate tools + rather than making one tool's response shape depend on its arguments. +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. + +### Consequences + +- LLM search hit rates improve substantially for paraphrased and misspelled + queries; results from all three search tools are ranked and carry `score` + and `matchedFields`. Work package and wiki page results additionally carry + a top-level `snippet`; meeting results carry a `snippet` per matched agenda + item instead. +- Enrichment failures now surface as `degraded` / `enrichmentFailures` on the + meeting and work package search pages, rather than being silently + swallowed. Previously an expired token during a meeting search produced "no + results" instead of an auth error. +- `src/search/` is pure and holds no module-level state, so it does not + reintroduce the cross-tenant cache exposure fixed in commit `7097d1c`. +- Two accepted limitations: a record whose only match is deep content is + findable only within the 25-item enrichment window (see spec section 6.1), + and wiki search remains bounded by the ID 1..50 discovery probe (spec + section 6.2). +- Scoring constants are tuning parameters. `tests/search/recall.test.ts` is + the acceptance test that governs them. + diff --git a/docs/TODO.md b/docs/TODO.md index 951f10a..1f5f8b7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -125,6 +125,27 @@ - [x] Added live OpenProject 17 container integration tests in `tests/services.test.ts` and `tests/mcp-server.test.ts`. - [x] Recorded [ADR-018](DECISIONS.md#adr-018-add-meetings-wikis-and-activities-tools-with-smart-wiki-discovery-and-deep-meeting-search) and updated [ARCHITECTURE.md](ARCHITECTURE.md), [TODO.md](TODO.md), and [README.md](../README.md). +- [x] **Task 10: Fuzzy Search by Default (19 Tools Total)** + - [x] Implemented a pure, zero-dependency fuzzy-matching unit in `src/search/` (`tokenize.ts`, `score.ts`, `snippet.ts`, `rank.ts`, `pipeline.ts`), with no I/O and no module-level mutable state. + - [x] Made fuzzy matching the default across all search tools via an optional `matchMode: "fuzzy" | "exact"` parameter; `"exact"` reproduces the previous substring-containment behavior byte for byte. + - [x] Widened content coverage: wiki page body text, work package descriptions and comments, and meeting participants and project names are now searchable, not just titles/subjects. + - [x] Added `openproject_search_work_packages`, exposing the previously tool-unreachable `searchWorkPackages` service helper as a ranked search tool over subjects, descriptions, and comments. + - [x] Added optional `query`/`matchMode` ranking to `openproject_list_work_package_activities` (ranks already-fetched activities client-side, no extra API cost). + - [x] Fixed two live bugs: a `.catch(() => undefined)` in meeting search that turned an expired token into a silent "no results found", and an unbounded `Promise.all` over up to 250 candidates in meeting search, replaced by the bounded two-phase pipeline (25-item enrichment window, concurrency 8). + - [x] Added `tests/search/` unit tests (`tokenize`, `score`, `snippet`, `rank`, `pipeline`) and a recall suite (`tests/search/recall.test.ts`) covering realistic natural-language and misspelled queries. + - [x] Recorded [ADR-019](DECISIONS.md#adr-019-fuzzy-search-by-default-with-a-hand-rolled-zero-dependency-scorer) and updated [ARCHITECTURE.md](ARCHITECTURE.md), [TODO.md](TODO.md), and [README.md](../README.md). + +### Known Limitations (Accepted) + +- **Two-phase recall ceiling**: A record whose only match is in deep content (a work package description/comment, a meeting agenda note or outcome) is findable only if it lands in the 25-item enrichment window (top-by-shallow-score, backfilled by recency). Beyond that window, deep-only matches are missed. See `src/search/pipeline.ts` (`DEFAULT_ENRICH_LIMIT`). +- **Wiki discovery probe window**: `searchWikiPages` builds its cache by probing wiki page IDs 1..50 with a 5-consecutive-miss cutoff. Pages beyond that window, or past a gap of five consecutive deleted/missing IDs, are invisible to search regardless of ranking quality. See `src/services/wikis.ts`. + +### Follow-ups (Tracked, Out of This Plan's Scope) + +- [ ] `getMeeting` in `src/services/meetings.ts` still swallows auth errors via a `.catch(() => undefined)` on its agenda-item fetch (same bug class fixed for `searchMeetings`). An expired token can still surface as a silently empty meeting detail on that path. +- [ ] `tests/search/recall.test.ts` under-tests ranking-under-competition: most cases have only one corpus document with any lexical overlap, so the suite largely proves "clears the minScore threshold" rather than "ranks the right document above plausible near-misses". Widening the corpus with near-miss documents would strengthen it. +- [ ] `SearchWorkPackagesOptions.typeId` / `assigneeId` (in `src/services/work-packages.ts`) are unreachable from the MCP tool — `searchWorkPackagesShape` in `src/tools/work-packages.ts` never exposes them. Dead surface area worth trimming or wiring up. + --- ## 3. Backlog & Future Phases @@ -136,6 +157,11 @@ - [x] Meetings listing, detail inspection, and deep agenda search. - [x] Wiki pages retrieval, smart cached discovery search, and work package links. - [x] Work package activities and comments filtering. +- [x] **Phase 3.5: Fuzzy Search by Default (Completed - 19 Tools Total)** + - [x] Zero-dependency fuzzy scorer (`src/search/`) applied by default across all search tools, with `matchMode: "exact"` opt-out. + - [x] Widened content coverage (wiki bodies, work package descriptions/comments, meeting participants/project names). + - [x] New `openproject_search_work_packages` tool; ranked activities via `openproject_list_work_package_activities`. + - [x] Two known, accepted limitations recorded above (two-phase recall ceiling, wiki discovery probe window). - [ ] **Phase 2: Mutating Operations** - [ ] Create work package tool (`openproject_create_work_package`). - [ ] Update work package tool (`openproject_update_work_package`).