Files
Orchestrator/bahn/teamlandkarte-mcp/openspec/changes/add-bm25-rrf-competence-matching/tasks.md
T
ankn a5f8fb49ab Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
2026-06-30 20:39:52 +02:00

587 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Implementation Tasks: BM25 + RRF Competence Matching
## Status: Implemented
## Summary
This change introduces an optional BM25-based competence matching strategy with optional
LLM-based Auto-Tagging as an alternative to the existing embedding-based approach:
1. **Restore LLM client**: `AzureOpenAIClient.chat_completion()` restored; `AzureOpenAIConfig.chat_deployment` + `llm_api_key` added.
2. **New `auto_tagger.py` module**: `AutoTagger` class with `expand_competences()` — ephemeral LLM-driven competence expansion.
3. **New `bm25.py` module**: `Bm25Index` dataclass + `bm25_rank_competences()` convenience function.
4. **New `rrf.py` module**: `reciprocal_rank_fusion()` fuses ranked lists into a normalized score dict.
5. **`similarity.py` extended**: `_bm25_rrf_similarity()` internal method + auto-tag branch + flag routing in `compute_competence_similarity()`.
6. **`SimilarityConfig` extended**: new `use_bm25_search: bool = False` and `use_auto_tagging: bool = False` fields.
7. **`config.py` parser extended**: reads `use_bm25_search`, `use_auto_tagging`, `chat_deployment`, and `AZURE_OPENAI_LLM_API_KEY`.
8. **`config.toml.example` updated**: documents all new keys.
9. **`mcp_server.py` updated**: constructs `AutoTagger` when `use_auto_tagging=True`; passes flags to `SimilarityEngine`.
10. **Legacy debug files removed**: 4 root-level files no longer part of the module.
11. **Tests added**: `tests/test_bm25.py`, `tests/test_rrf.py`, `tests/test_auto_tagger.py`, extensions to `tests/test_similarity.py` and `tests/test_matcher_integration.py`.
12. **Documentation updated**: architecture, tradeoffs doc, assistant prompt, README.
## Clarifications / Decisions
- **BM25 scope**: BM25 + RRF replaces **competence** similarity only. Role similarity is
always embedding-based, regardless of `use_bm25_search`.
- **Auto-tagging scope**: `use_auto_tagging` only applies when `use_bm25_search = true`.
When `use_auto_tagging = true`, `AutoTagger.expand_competences()` is called once per
candidate per `compute_competence_similarity()` invocation. Result is ephemeral.
- **Auto-tagging works in both directions**: both `find_matching_capacities` and
`find_matching_tasks` pass through `compute_competence_similarity()` and benefit.
- **Graceful degradation**: if the LLM call in `AutoTagger` fails for any reason, the
original candidate list is used unchanged. BM25 scoring continues.
- **LLM client restored**: `AzureOpenAIClient.chat_completion()` is re-added, scoped
exclusively to auto-tagging. The embedding-related API surface is unchanged.
- **Zero-out rule**: If all BM25 scores for a required competence are 0.0 (no token
overlap at all), the RRF score for that required competence is also 0.0.
- **Global index** *(revised)*: Originally the `Bm25Index` was to be built fresh per
`compute_competence_similarity()` call. This was identified as an IDF pathology: with
only one person's competences as the corpus, BM25 IDF is always 0 or negative for any
term that appears in every document (and with N=1 all terms do). The fix (Phase 9) builds
one `Bm25Index` over the union of all filtered candidates' competences before the loop
and passes it in as `global_index`.
- **Single ranked list**: Initial implementation fuses one BM25 ranked list per required
competence. The `reciprocal_rank_fusion()` signature accepts multiple lists for future
hybrid extension.
- **Output shape unchanged**: `_bm25_rrf_similarity()` returns the same
`dict[str, dict[str, object]]` shape as `_per_skill_similarity()`. Matcher, scorer,
and all output formatting remain unchanged.
- **`rank-bm25` dependency**: Added to `pyproject.toml` `[project.dependencies]`.
- **All new code uses Google Docstrings** per project convention.
- **Default `false`**: Existing deployments are unaffected until opt-in.
## Estimated Effort
- **Phase 0 (Restore LLM client)**: 1 hour
- **Phase 1 (New modules — bm25, rrf, auto_tagger)**: 34 hours
- **Phase 2 (Config + wiring)**: 11.5 hours
- **Phase 3 (Similarity integration)**: 2 hours
- **Phase 4 (Legacy cleanup)**: 0.5 hour
- **Phase 5 (Tests)**: 45 hours
- **Phase 6 (Documentation)**: 2 hours
- **Phase 7 (Dependency)**: 0.5 hour
- **Phase 8 (Quality gates)**: 0.5 hour
- **Total**: 1517 hours
---
## Phase 0: Restore LLM Client
### Task 0.1: Extend `AzureOpenAIConfig`
**File:** `src/teamlandkarte_mcp/config.py`
**Subtasks:**
- [x] Add `chat_deployment: str = ""` field to `AzureOpenAIConfig` dataclass
- [x] Add Google Docstring field comment:
`"""Azure deployment name for the chat model used in auto-tagging (e.g. 'gpt-4.1'). Required when use_auto_tagging = true."""`
- [x] Update the TOML parser to read `azure_openai.chat_deployment` with default `""`
- [x] Read `AZURE_OPENAI_LLM_API_KEY` from environment (store as `llm_api_key: str = ""` on `AzureOpenAIConfig`)
- [x] When `use_auto_tagging = true` and `llm_api_key` is empty, raise `ConfigurationError` at startup
**Acceptance:**
- `AzureOpenAIConfig()` instantiates with `chat_deployment=""` and `llm_api_key=""`
- TOML `azure_openai.chat_deployment = "gpt-4.1"` is read correctly
- Missing `AZURE_OPENAI_LLM_API_KEY` with `use_auto_tagging=true` raises at startup
---
### Task 0.2: Add `chat_completion()` to `AzureOpenAIClient`
**File:** `src/teamlandkarte_mcp/azure/openai_client.py`
**Subtasks:**
- [x] Add `chat_deployment: str` and `llm_api_key: str` constructor parameters
- [x] Implement `chat_completion(system: str, user: str) -> str` method:
- Calls Azure OpenAI chat completions API with `response_format={"type": "json_object"}`
- Uses `self.chat_deployment` as the model/deployment
- Uses `self.llm_api_key` for auth (separate from embedding key)
- Returns the raw JSON string from the first choice's message content
- Raises `AzureOpenAIError` on API errors (consistent with existing error handling)
- [x] Add Google Docstring to `chat_completion()`
**Acceptance:**
- `chat_completion(system="...", user="...")` returns a JSON string
- API errors surface as `AzureOpenAIError`
- `chat_deployment=""` + `chat_completion()` call raises `ConfigurationError` (misconfigured guard)
---
## Phase 1: New Modules
### Task 1.1: Add `bm25.py`
**File:** `src/teamlandkarte_mcp/matching/bm25.py` (**new**)
**Subtasks:**
- [x] Create `bm25.py` with `_tokenize(text: str) -> list[str]` helper (lowercase, split on `[\W_]+`, filter empty)
- [x] Implement `Bm25Index` dataclass:
- `corpus: list[str]` field
- Internal `_bm25: BM25Okapi | None` built in `__post_init__`
- `rank(query: str) -> list[tuple[str, float]]` method:
- returns `[]` for empty corpus
- returns `(corpus_text, bm25_score)` list sorted by score descending
- score 0.0 entries are included (caller decides cutoff)
- [x] Implement `bm25_rank_competences(required: str, candidates: list[str]) -> list[tuple[str, float]]` convenience function
- [x] Add Google Docstrings to all public symbols
**Acceptance:**
- `Bm25Index(corpus=[]).rank("Python")``[]`
- `Bm25Index(corpus=["Python"]).rank("Python")``[("Python", score > 0)]`
- `Bm25Index(corpus=["JavaScript"]).rank("Python")``[("JavaScript", 0.0)]`
- `Bm25Index(corpus=["Machine Learning"]).rank("machine learning")` → score > 0 (case-insensitive)
---
### Task 1.2: Add `rrf.py`
**File:** `src/teamlandkarte_mcp/matching/rrf.py` (**new**)
**Subtasks:**
- [x] Implement `reciprocal_rank_fusion(ranked_lists, *, k=60) -> dict[str, float]`:
- Skip any list where all scores are 0.0 (no token-overlap signal)
- For each candidate in each non-zero list, accumulate `1 / (k + rank)` (1-based rank)
- Normalize so the highest-scoring candidate maps to 1.0
- Return `{}` if all lists are empty or all-zero
- [x] Add Google Docstring including mathematical formula, zero-out rule, and normalization description
**Acceptance:**
- Single list, one candidate → `{"A": 1.0}`
- Single list `[("A", 1.0), ("B", 0.5)]``{"A": 1.0, "B": ...}` with `0 < B < 1`
- Single all-zero list `[("A", 0.0), ("B", 0.0)]``{}` (empty, no signal)
- `k=60` default: rank-1 score = `1/61`, rank-2 score = `1/62`, normalized → `61/62 ≈ 0.984`
---
### Task 1.3: Add `auto_tagger.py`
**File:** `src/teamlandkarte_mcp/matching/auto_tagger.py` (**new**)
**Subtasks:**
- [x] Implement `AutoTagger` class:
- Constructor: `__init__(self, client: AzureOpenAIClient) -> None`
- Stores the client reference
- [x] Implement `expand_competences(self, required: list[str], existing: list[str]) -> list[str]`:
- Build system prompt: instructs LLM to return only required-competence names already
covered by existing entries (synonym/abbreviation/cross-language), as JSON
`{"additions": ["...", ...]}`
- Build user prompt: `REQUIRED: {required}\nEXISTING: {existing}`
- Call `self._client.chat_completion(system=..., user=...)` → JSON string
- Parse JSON, extract `additions` list
- Validate: keep only items present in `required` (guard against LLM hallucination)
- Return `existing + [a for a in additions if a not in existing]`
- On any exception (API error, JSON parse, key error): log warning, return `existing` unchanged
- [x] Add Google Docstrings to class and all public methods
**Acceptance:**
- LLM returns `{"additions": ["Machine Learning"]}` for existing `["ML"]`, required `["Machine Learning"]`
→ returns `["ML", "Machine Learning"]`
- LLM returns `{"additions": []}` → returns `existing` unchanged
- LLM raises exception → returns `existing` unchanged (no exception propagation)
- Addition not in `required` is silently dropped (hallucination guard)
- No duplicate entries if `existing` already contains the addition
---
## Phase 2: Configuration Extension
### Task 2.1: Extend `SimilarityConfig`
**File:** `src/teamlandkarte_mcp/config.py`
**Subtasks:**
- [x] Add `use_bm25_search: bool = False` field to `SimilarityConfig` dataclass
- [x] Add `use_auto_tagging: bool = False` field to `SimilarityConfig` dataclass
- [x] Add Google Docstring comments to both fields
- [x] Update the TOML parser to read `matching.similarity.use_bm25_search` (default `False`)
- [x] Update the TOML parser to read `matching.similarity.use_auto_tagging` (default `False`)
**Acceptance:**
- `SimilarityConfig()` instantiates with both flags `False`
- `SimilarityConfig(use_bm25_search=True, use_auto_tagging=True)` works
- Parser reads both flags from TOML correctly
---
### Task 2.2: Update `config.toml.example`
**File:** `config.toml.example`
**Subtasks:**
- [x] Add `use_bm25_search = false` under `[matching.similarity]` with inline comment
- [x] Add `use_auto_tagging = false` under `[matching.similarity]` with inline comment:
```toml
# When true, BM25 + RRF replaces embedding-based competence similarity.
# BM25 assigns 0 to candidates with no token overlap, eliminating false positives.
# Role similarity is always embedding-based. Default: false.
use_bm25_search = false
# When true (and use_bm25_search = true), an LLM pre-expands each candidate's
# competence list with canonical equivalents of required competences it already covers
# (synonyms, abbreviations, cross-language). The expansion is ephemeral. Default: false.
use_auto_tagging = false
```
- [x] Add `chat_deployment = ""` under `[azure_openai]` with inline comment:
```toml
# Azure deployment name for the chat model used in auto-tagging (e.g. "gpt-4.1").
# Required when use_auto_tagging = true.
chat_deployment = ""
```
**Acceptance:**
- Config example is self-documenting for all three new keys
- All new defaults are `false` / `""`
---
### Task 2.3: Wire `use_bm25_search`, `use_auto_tagging`, and `AutoTagger` in `mcp_server.py`
**File:** `src/teamlandkarte_mcp/mcp_server.py`
**Subtasks:**
- [x] When `use_auto_tagging=True`: construct `AzureOpenAIClient` with `chat_deployment`
and `llm_api_key`; construct `AutoTagger(client=llm_client)`
- [x] Pass `auto_tagger` (or `None`) and both flags to `SimilarityEngine` constructor
- [x] When `use_auto_tagging=False`: `auto_tagger=None` — no LLM client constructed
**Acceptance:**
- Setting `use_auto_tagging = true` in `config.toml` activates auto-tagging at runtime
- Setting `use_auto_tagging = false` (default) does not construct any LLM client
---
## Phase 3: Similarity Engine Integration
### Task 3.1: Add `_bm25_rrf_similarity()` to `SimilarityEngine`
**File:** `src/teamlandkarte_mcp/matching/similarity.py`
**Subtasks:**
- [x] Import `bm25_rank_competences` from `.bm25`, `reciprocal_rank_fusion` from `.rrf`,
and `AutoTagger` from `.auto_tagger`
- [x] Implement `_bm25_rrf_similarity(self, required: list[str], candidate: list[str]) -> dict[str, dict[str, object]]`:
- For each `req` in `required`:
1. Call `bm25_rank_competences(req, candidate)` → `ranked`
2. Call `reciprocal_rank_fusion([ranked])` → `fused: dict[str, float]`
3. If `fused` is empty or all scores are 0.0:
- `score = 0.0`, `best_match = None`, `rationale = "BM25: no token overlap with any candidate competence."`
4. Otherwise:
- `best_match = max(fused, key=fused.get)`
- `score = fused[best_match]`
- `rationale = f"BM25+RRF: best match '{best_match}' (score {score:.3f})."`
- Returns dict shaped identically to `_per_skill_similarity()` output
- [x] Add Google Docstring to `_bm25_rrf_similarity()`
---
### Task 3.2: Route `compute_competence_similarity()` by flags
**File:** `src/teamlandkarte_mcp/matching/similarity.py`
**Subtasks:**
- [x] Ensure `SimilarityEngine.__init__` accepts and stores `use_bm25_search`,
`use_auto_tagging`, and `auto_tagger: AutoTagger | None = None`
- [x] In `compute_competence_similarity()`:
```python
if self.config.use_bm25_search:
working = candidate
if self.config.use_auto_tagging and self._auto_tagger is not None:
working = self._auto_tagger.expand_competences(required, candidate)
return self._bm25_rrf_similarity(required, working)
# else: existing strategy dispatch (unchanged)
```
**Acceptance:**
- `use_bm25_search=False`: behavior bit-for-bit identical to before
- `use_bm25_search=True, use_auto_tagging=False`: BM25 on raw candidate list
- `use_bm25_search=True, use_auto_tagging=True`: BM25 on LLM-expanded list
- `use_bm25_search=True` + required=`["Python"]`, candidate=`["JavaScript"]`: `score == 0.0`
- `use_bm25_search=True` + required=`["Python"]`, candidate=`["Python"]`: `score == 1.0`
---
## Phase 4: Legacy Cleanup
### Task 4.1: Remove legacy debug files
**Files to delete:**
- `debug_similarity_analysis.py`
- `debug_categorization.py`
- `SEMANTIC_SIMILARITY_ANALYSIS.md`
- `SCORE_BUG_FIX.md`
**Subtasks:**
- [x] Verify none of these files are imported or referenced in source code or tests
- [x] Delete `debug_similarity_analysis.py`
- [x] Delete `debug_categorization.py`
- [x] Delete `SEMANTIC_SIMILARITY_ANALYSIS.md`
- [x] Delete `SCORE_BUG_FIX.md`
**Acceptance:**
- `git status` shows 4 deleted files
- No import errors in existing code
---
## Phase 5: Tests
### Task 5.1: Unit tests for `bm25.py`
**File:** `tests/test_bm25.py` (**new**)
**Subtasks:**
- [x] Test `Bm25Index` with empty corpus → `rank()` returns `[]`
- [x] Test exact match (single token) → score > 0
- [x] Test no token overlap (query "Python", corpus "JavaScript") → score == 0.0
- [x] Test partial token overlap multi-token query ("Machine Learning") against ("Deep Learning") → partial score > 0
- [x] Test case-insensitivity: `rank("Python")` matches "python" in corpus
- [x] Test tokenization edge cases:
- "Progressive Web App (PWA)" → `["progressive", "web", "app", "pwa"]`
- "CI/CD Pipeline" → `["ci", "cd", "pipeline"]`
- "React.js" → `["react", "js"]`
- [x] Test `bm25_rank_competences()` convenience wrapper produces same result as `Bm25Index.rank()`
- [x] Add Google Docstrings to test module
### Task 5.2: Unit tests for `rrf.py`
**File:** `tests/test_rrf.py` (**new**)
**Subtasks:**
- [x] Test single list, single candidate → `{"A": 1.0}`
- [x] Test single list multiple candidates: top candidate maps to 1.0, others < 1.0 and > 0.0
- [x] Test all-zero list → returns `{}` (empty)
- [x] Test mixed: one non-zero list + one all-zero list → only non-zero list contributes
- [x] Test `k` parameter: higher `k` flattens differences (rank-1 and rank-2 scores closer together)
- [x] Test score ordering is preserved (rank 1 > rank 2 > rank 3 in output)
- [x] Add Google Docstrings to test module
### Task 5.3: Unit tests for `auto_tagger.py`
**File:** `tests/test_auto_tagger.py` (**new**)
**Subtasks:**
- [x] Mock `AzureOpenAIClient.chat_completion()` for all tests (no real LLM calls)
- [x] Test: LLM returns `{"additions": ["Machine Learning"]}` for existing `["ML"]`,
required `["Machine Learning"]` → result is `["ML", "Machine Learning"]`
- [x] Test: LLM returns `{"additions": []}` → result is identical to `existing`
- [x] Test: LLM `chat_completion` raises exception → result is identical to `existing`,
no exception propagated
- [x] Test: LLM returns malformed JSON (not parseable) → result is `existing` unchanged
- [x] Test: LLM returns addition not in `required` (hallucination) → addition is dropped
- [x] Test: LLM returns addition already in `existing` → no duplicate in result
- [x] Test: empty `required` list → `expand_competences()` returns `existing` unchanged
(no LLM call needed)
- [x] Add Google Docstrings to test module
### Task 5.4: Extend `tests/test_similarity.py`
**File:** `tests/test_similarity.py` (extended)
**Subtasks:**
- [x] Add tests with `use_bm25_search=True`:
- Required `["Python"]`, candidate `["JavaScript"]` → `competence_score == 0.0`
- Required `["Python"]`, candidate `["Python"]` → `competence_score == 1.0`
- Required `["Python", "Machine Learning"]`, candidate `["JavaScript", "TypeScript"]` → both individual scores 0.0, overall `competence_score == 0.0`
- Required `["Python", "Machine Learning"]`, candidate `["Python", "Machine Learning"]` → both scores 1.0
- [x] Add tests with `use_bm25_search=True, use_auto_tagging=True` (mocked `AutoTagger`):
- Mocked `AutoTagger.expand_competences` injects `"Machine Learning"` into `["ML"]`
- Required `["Machine Learning"]`, candidate `["ML"]` (after expansion) → `score > 0`
- [x] Confirm that with `use_bm25_search=False`, behavior is unchanged (existing tests still pass)
### Task 5.5: Extend `tests/test_matcher_integration.py`
**File:** `tests/test_matcher_integration.py` (extended)
**Subtasks:**
- [x] Add integration test with `use_bm25_search=True`:
- Task requires: `["Python", "Machine Learning"]`
- Candidate A has: `["Python", "Machine Learning", "Pandas"]` → high score
- Candidate B has: `["JavaScript", "TypeScript", "Node.js", "Vue.js"]` → score 0.0
- Assert: candidate A `competence_score` > candidate B `competence_score`
- Assert: candidate B `competence_score == 0.0` (false-positive eliminated)
- [x] Add integration test with `use_bm25_search=True, use_auto_tagging=True`
(mocked LLM via mocked `AutoTagger`):
- Candidate has `["ML", "Python"]`; auto-tag adds `"Machine Learning"` to working list
- Required `["Machine Learning", "Python"]` → both scores > 0
- [x] These tests explicitly document the false-positive scenario from production
---
## Phase 6: Documentation
### Task 6.1: Update `docs/architecture.md`
**File:** `docs/architecture.md`
**Subtasks:**
- [x] Add section "BM25 + RRF + Auto-Tagging Competence Matching" under the matching/similarity section:
- Describe the three new modules (`auto_tagger.py`, `bm25.py`, `rrf.py`)
- Describe the auto-tag → BM25 → RRF pipeline and routing in `compute_competence_similarity()`
- Note that role similarity is unaffected
- Reference `use_bm25_search` and `use_auto_tagging` config keys
### Task 6.2: Update `docs/semantic_similarity_tradeoffs.md`
**File:** `docs/semantic_similarity_tradeoffs.md`
**Subtasks:**
- [x] Add BM25+RRF row to the strategy comparison table:
- Strengths: exact/lexical match, no false positives for non-overlapping skills
- Weaknesses: misses synonyms and cross-language variants (mitigated by `use_auto_tagging`)
- When to use: when skill names in the dataset are mostly canonical English terms
- [x] Add BM25+RRF+AutoTag row:
- Strengths: exact match + synonym/abbreviation/cross-language bridging via LLM
- Weaknesses: LLM latency per candidate, requires `AZURE_OPENAI_LLM_API_KEY`
- When to use: mixed-language or abbreviation-heavy skill datasets
### Task 6.3: Update `docs/assistant_system_prompt.md`
**File:** `docs/assistant_system_prompt.md`
**Subtasks:**
- [x] Add note on score interpretation when `use_bm25_search=true`:
- A `competence_score` of 0.0 means **no lexical token overlap** between required and candidate competences
- The score is not a semantic similarity — it is a lexical matching score
- Users should be informed when BM25 mode is active so they understand why cross-language synonyms may not match
- [x] Add note on `use_auto_tagging=true`:
- Auto-tagging bridges common synonym/abbreviation gaps (e.g. "ML" → "Machine Learning")
- A positive `competence_score` with auto-tagging enabled may reflect an LLM-inferred equivalence,
not a literal token match; the rationale field will say "BM25+RRF" regardless
### Task 6.4: Update `README.md`
**File:** `README.md`
**Subtasks:**
- [x] Add all three new config keys to the config reference section:
- `matching.similarity.use_bm25_search` (boolean, default `false`): Enables BM25+RRF lexical competence matching
- `matching.similarity.use_auto_tagging` (boolean, default `false`): Enables LLM pre-expansion before BM25
- `azure_openai.chat_deployment` (string, default `""`): Chat model deployment name for auto-tagging
- [x] Document `AZURE_OPENAI_LLM_API_KEY` environment variable in the env-vars section
---
## Phase 7: Dependency
### Task 7.1: Add `rank-bm25` to project dependencies
**File:** `pyproject.toml`
**Subtasks:**
- [x] Add `rank-bm25` to `[project.dependencies]` in `pyproject.toml`
- [x] Run `uv lock` (or equivalent) to update the lockfile
- [x] Verify `from rank_bm25 import BM25Okapi` works in the environment
**Acceptance:**
- `uv run python -c "from rank_bm25 import BM25Okapi; print('ok')"` exits 0
---
## Phase 8: Quality Gates
### Task 8.1: Run unit tests
- [x] `uv run pytest tests/test_bm25.py tests/test_rrf.py tests/test_auto_tagger.py -v`
- [x] `uv run pytest -m "not integration" -q`
- [x] All tests pass
### Task 8.2: Run linters
- [x] `uv run ruff check src/ tests/`
- [x] `uv run mypy src/teamlandkarte_mcp/matching/bm25.py src/teamlandkarte_mcp/matching/rrf.py src/teamlandkarte_mcp/matching/auto_tagger.py`
- [x] Zero errors
### Task 8.3: OpenSpec validation
- [x] `openspec validate add-bm25-rrf-competence-matching --strict`
- [x] All checks pass
### Task 8.4: Confirm legacy files are removed
- [x] `ls debug_*.py` → no such files
- [x] `ls SEMANTIC_SIMILARITY_ANALYSIS.md SCORE_BUG_FIX.md` → no such files
---
## Phase 9: Architectural Fix — Global BM25 Index
**Problem identified post-spec**: When `Bm25Index` was built per candidate (N = number of that
person's competences), IDF values were always 0 or negative for every term — the library
`rank_bm25` computes `log((N - df + 0.5) / (df + 0.5))`, and with N=1 every term has df=1,
yielding `log(0)` = negative. All BM25 scores were therefore ≤ 0, making the `max(0.0, raw)`
clamp produce a flat 0.0 for everything. The fix builds one global corpus from the union of
all filtered candidates' competences, ensuring N is large enough for IDF to be meaningful.
### Task 9.1: Remove `1e-10` clamp in `bm25.py`
**File:** `src/teamlandkarte_mcp/matching/bm25.py`
**Subtasks:**
- [x] Remove the special-case block that returned `1e-10` for zero/negative BM25 scores
when token overlap existed (a band-aid for the IDF pathology)
- [x] Replace with `max(0.0, float(raw))` — ubiquitous terms (negative IDF) simply score 0.0
- [x] Update `Bm25Index` and `bm25_rank_competences()` docstrings to reflect the change
**Acceptance:**
- A term present in every document of a sufficiently large corpus scores 0.0, not 1e-10
---
### Task 9.2: Add `use_bm25_search` property to `SimilarityEngine`
**File:** `src/teamlandkarte_mcp/matching/similarity.py`
**Subtasks:**
- [x] Expose `use_bm25_search: bool` as a read-only property (or attribute) on `SimilarityEngine`
so `Matcher` can check it without accessing internal config directly
**Acceptance:**
- `engine.use_bm25_search` returns the value of `engine.config.use_bm25_search`
---
### Task 9.3: Thread `global_index` parameter through similarity layer
**File:** `src/teamlandkarte_mcp/matching/similarity.py`
**Subtasks:**
- [x] Add `global_index: Bm25Index | None = None` parameter to `compute_competence_similarity()`
- [x] Pass it through to `_bm25_rrf_similarity()`
- [x] In `_bm25_rrf_similarity()`: when `global_index is not None`, call
`global_index.rank(req)` and filter the result list to only entries whose text is in
`set(candidate)`; otherwise fall back to `bm25_rank_competences(req, candidate)`
**Acceptance:**
- `global_index.rank(req)` is called once per required competence, not once per candidate
- Results are correctly filtered to the current candidate's competence set
- Fall-back path (`global_index=None`) still works unchanged
---
### Task 9.4: Build global corpus in `Matcher` before per-candidate loop
**File:** `src/teamlandkarte_mcp/matching/matcher.py`
**Subtasks:**
- [x] Before the `for cap in filtered` loop: if `self._sim.use_bm25_search` and `filtered`
is non-empty, collect the deduplicated union of all candidates' competences into a list
`global_corpus`, instantiate `Bm25Index(corpus=global_corpus)`
- [x] Pass `global_index` to every `compute_competence_similarity()` call inside the loop
- [x] When `use_bm25_search=False` (or `filtered` is empty): `global_bm25_index = None`
(no-op, existing behaviour)
**Acceptance:**
- A corpus of N candidates' competences is built once, not N times
- Candidates with no overlap with required competences still score 0.0
- Candidates with overlap score > 0.0 (IDF is meaningful because N >> 1 in the global corpus)
---
### Task 9.5: Update tests for global index
**Files:** `tests/test_bm25.py`, `tests/test_similarity.py`, `tests/test_matcher_integration.py`, `tests/test_matching_refinements.py`
**Subtasks:**
- [x] `test_bm25.py`: update small-corpus tests to use ≥ 5-doc corpora so IDF is positive;
add `test_bm25_index_rank_filters_to_candidate_subset` and
`test_bm25_ubiquitous_term_clamped_to_zero`
- [x] `test_similarity.py`: update 4 BM25 tests to pass a 5-doc `global_index`; add
`test_use_bm25_search_property_reflects_flag` and `test_bm25_global_index_filters_to_candidate_subset`
- [x] `test_matcher_integration.py`: add `use_bm25_search = False` to `_FakeSimilarityEngine`,
add `global_index=None` param to `compute_competence_similarity`; update
`test_bm25_auto_tag_candidate_matches_after_expansion` to use a 5-doc global corpus;
add `test_matcher_builds_global_bm25_index_across_all_candidates`
- [x] `test_matching_refinements.py`: add `use_bm25_search = False` and `global_index=None`
to local `_FakeSim` stub
**Acceptance:**
- All 140 tests pass, 2 skipped
---
### Task 9.6: Update `docs/architecture.md`
**File:** `docs/architecture.md`
**Subtasks:**
- [x] Replace the incorrect "Per-call index" bullet in §9.3 with a "Global BM25 index" bullet
describing the pre-loop construction and per-candidate filtering
- [x] Update the §9.1 module table row for `bm25.py` to remove the `1e-10` clamp description
and replace with `max(0.0, raw)` description
- [x] Update the §9.2 data flow diagram to show `match_candidates` building the global index
before the per-candidate loop
**Acceptance:**
- No mention of "per-call index" or `1e-10` clamp remains in the docs