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.
27 KiB
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:
- Restore LLM client:
AzureOpenAIClient.chat_completion()restored;AzureOpenAIConfig.chat_deployment+llm_api_keyadded. - New
auto_tagger.pymodule:AutoTaggerclass withexpand_competences()— ephemeral LLM-driven competence expansion. - New
bm25.pymodule:Bm25Indexdataclass +bm25_rank_competences()convenience function. - New
rrf.pymodule:reciprocal_rank_fusion()fuses ranked lists into a normalized score dict. similarity.pyextended:_bm25_rrf_similarity()internal method + auto-tag branch + flag routing incompute_competence_similarity().SimilarityConfigextended: newuse_bm25_search: bool = Falseanduse_auto_tagging: bool = Falsefields.config.pyparser extended: readsuse_bm25_search,use_auto_tagging,chat_deployment, andAZURE_OPENAI_LLM_API_KEY.config.toml.exampleupdated: documents all new keys.mcp_server.pyupdated: constructsAutoTaggerwhenuse_auto_tagging=True; passes flags toSimilarityEngine.- Legacy debug files removed: 4 root-level files no longer part of the module.
- Tests added:
tests/test_bm25.py,tests/test_rrf.py,tests/test_auto_tagger.py, extensions totests/test_similarity.pyandtests/test_matcher_integration.py. - 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_taggingonly applies whenuse_bm25_search = true. Whenuse_auto_tagging = true,AutoTagger.expand_competences()is called once per candidate percompute_competence_similarity()invocation. Result is ephemeral. - Auto-tagging works in both directions: both
find_matching_capacitiesandfind_matching_taskspass throughcompute_competence_similarity()and benefit. - Graceful degradation: if the LLM call in
AutoTaggerfails 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
Bm25Indexwas to be built fresh percompute_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 oneBm25Indexover the union of all filtered candidates' competences before the loop and passes it in asglobal_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 samedict[str, dict[str, object]]shape as_per_skill_similarity(). Matcher, scorer, and all output formatting remain unchanged. rank-bm25dependency: Added topyproject.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): 3–4 hours
- Phase 2 (Config + wiring): 1–1.5 hours
- Phase 3 (Similarity integration): 2 hours
- Phase 4 (Legacy cleanup): 0.5 hour
- Phase 5 (Tests): 4–5 hours
- Phase 6 (Documentation): 2 hours
- Phase 7 (Dependency): 0.5 hour
- Phase 8 (Quality gates): 0.5 hour
- Total: 15–17 hours
Phase 0: Restore LLM Client
Task 0.1: Extend AzureOpenAIConfig
File: src/teamlandkarte_mcp/config.py
Subtasks:
- Add
chat_deployment: str = ""field toAzureOpenAIConfigdataclass - 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.""" - Update the TOML parser to read
azure_openai.chat_deploymentwith default"" - Read
AZURE_OPENAI_LLM_API_KEYfrom environment (store asllm_api_key: str = ""onAzureOpenAIConfig) - When
use_auto_tagging = trueandllm_api_keyis empty, raiseConfigurationErrorat startup
Acceptance:
AzureOpenAIConfig()instantiates withchat_deployment=""andllm_api_key=""- TOML
azure_openai.chat_deployment = "gpt-4.1"is read correctly - Missing
AZURE_OPENAI_LLM_API_KEYwithuse_auto_tagging=trueraises at startup
Task 0.2: Add chat_completion() to AzureOpenAIClient
File: src/teamlandkarte_mcp/azure/openai_client.py
Subtasks:
- Add
chat_deployment: strandllm_api_key: strconstructor parameters - Implement
chat_completion(system: str, user: str) -> strmethod:- Calls Azure OpenAI chat completions API with
response_format={"type": "json_object"} - Uses
self.chat_deploymentas the model/deployment - Uses
self.llm_api_keyfor auth (separate from embedding key) - Returns the raw JSON string from the first choice's message content
- Raises
AzureOpenAIErroron API errors (consistent with existing error handling)
- Calls Azure OpenAI chat completions API with
- 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 raisesConfigurationError(misconfigured guard)
Phase 1: New Modules
Task 1.1: Add bm25.py
File: src/teamlandkarte_mcp/matching/bm25.py (new)
Subtasks:
- Create
bm25.pywith_tokenize(text: str) -> list[str]helper (lowercase, split on[\W_]+, filter empty) - Implement
Bm25Indexdataclass:corpus: list[str]field- Internal
_bm25: BM25Okapi | Nonebuilt 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)
- returns
- Implement
bm25_rank_competences(required: str, candidates: list[str]) -> list[tuple[str, float]]convenience function - 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:
- 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
- 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": ...}with0 < B < 1 - Single all-zero list
[("A", 0.0), ("B", 0.0)]→{}(empty, no signal) k=60default: 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:
- Implement
AutoTaggerclass:- Constructor:
__init__(self, client: AzureOpenAIClient) -> None - Stores the client reference
- Constructor:
- 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
additionslist - 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
existingunchanged
- Build system prompt: instructs LLM to return only required-competence names already
covered by existing entries (synonym/abbreviation/cross-language), as JSON
- 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": []}→ returnsexistingunchanged - LLM raises exception → returns
existingunchanged (no exception propagation) - Addition not in
requiredis silently dropped (hallucination guard) - No duplicate entries if
existingalready contains the addition
Phase 2: Configuration Extension
Task 2.1: Extend SimilarityConfig
File: src/teamlandkarte_mcp/config.py
Subtasks:
- Add
use_bm25_search: bool = Falsefield toSimilarityConfigdataclass - Add
use_auto_tagging: bool = Falsefield toSimilarityConfigdataclass - Add Google Docstring comments to both fields
- Update the TOML parser to read
matching.similarity.use_bm25_search(defaultFalse) - Update the TOML parser to read
matching.similarity.use_auto_tagging(defaultFalse)
Acceptance:
SimilarityConfig()instantiates with both flagsFalseSimilarityConfig(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:
- Add
use_bm25_search = falseunder[matching.similarity]with inline comment - Add
use_auto_tagging = falseunder[matching.similarity]with inline comment:# 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 - Add
chat_deployment = ""under[azure_openai]with inline comment:# 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:
- When
use_auto_tagging=True: constructAzureOpenAIClientwithchat_deploymentandllm_api_key; constructAutoTagger(client=llm_client) - Pass
auto_tagger(orNone) and both flags toSimilarityEngineconstructor - When
use_auto_tagging=False:auto_tagger=None— no LLM client constructed
Acceptance:
- Setting
use_auto_tagging = trueinconfig.tomlactivates 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:
- Import
bm25_rank_competencesfrom.bm25,reciprocal_rank_fusionfrom.rrf, andAutoTaggerfrom.auto_tagger - Implement
_bm25_rrf_similarity(self, required: list[str], candidate: list[str]) -> dict[str, dict[str, object]]:- For each
reqinrequired:- Call
bm25_rank_competences(req, candidate)→ranked - Call
reciprocal_rank_fusion([ranked])→fused: dict[str, float] - If
fusedis empty or all scores are 0.0:score = 0.0,best_match = None,rationale = "BM25: no token overlap with any candidate competence."
- Otherwise:
best_match = max(fused, key=fused.get)score = fused[best_match]rationale = f"BM25+RRF: best match '{best_match}' (score {score:.3f})."
- Call
- Returns dict shaped identically to
_per_skill_similarity()output
- For each
- Add Google Docstring to
_bm25_rrf_similarity()
Task 3.2: Route compute_competence_similarity() by flags
File: src/teamlandkarte_mcp/matching/similarity.py
Subtasks:
- Ensure
SimilarityEngine.__init__accepts and storesuse_bm25_search,use_auto_tagging, andauto_tagger: AutoTagger | None = None - In
compute_competence_similarity():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 beforeuse_bm25_search=True, use_auto_tagging=False: BM25 on raw candidate listuse_bm25_search=True, use_auto_tagging=True: BM25 on LLM-expanded listuse_bm25_search=True+ required=["Python"], candidate=["JavaScript"]:score == 0.0use_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.pydebug_categorization.pySEMANTIC_SIMILARITY_ANALYSIS.mdSCORE_BUG_FIX.md
Subtasks:
- Verify none of these files are imported or referenced in source code or tests
- Delete
debug_similarity_analysis.py - Delete
debug_categorization.py - Delete
SEMANTIC_SIMILARITY_ANALYSIS.md - Delete
SCORE_BUG_FIX.md
Acceptance:
git statusshows 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:
- Test
Bm25Indexwith empty corpus →rank()returns[] - Test exact match (single token) → score > 0
- Test no token overlap (query "Python", corpus "JavaScript") → score == 0.0
- Test partial token overlap multi-token query ("Machine Learning") against ("Deep Learning") → partial score > 0
- Test case-insensitivity:
rank("Python")matches "python" in corpus - Test tokenization edge cases:
- "Progressive Web App (PWA)" →
["progressive", "web", "app", "pwa"] - "CI/CD Pipeline" →
["ci", "cd", "pipeline"] - "React.js" →
["react", "js"]
- "Progressive Web App (PWA)" →
- Test
bm25_rank_competences()convenience wrapper produces same result asBm25Index.rank() - Add Google Docstrings to test module
Task 5.2: Unit tests for rrf.py
File: tests/test_rrf.py (new)
Subtasks:
- Test single list, single candidate →
{"A": 1.0} - Test single list multiple candidates: top candidate maps to 1.0, others < 1.0 and > 0.0
- Test all-zero list → returns
{}(empty) - Test mixed: one non-zero list + one all-zero list → only non-zero list contributes
- Test
kparameter: higherkflattens differences (rank-1 and rank-2 scores closer together) - Test score ordering is preserved (rank 1 > rank 2 > rank 3 in output)
- Add Google Docstrings to test module
Task 5.3: Unit tests for auto_tagger.py
File: tests/test_auto_tagger.py (new)
Subtasks:
- Mock
AzureOpenAIClient.chat_completion()for all tests (no real LLM calls) - Test: LLM returns
{"additions": ["Machine Learning"]}for existing["ML"], required["Machine Learning"]→ result is["ML", "Machine Learning"] - Test: LLM returns
{"additions": []}→ result is identical toexisting - Test: LLM
chat_completionraises exception → result is identical toexisting, no exception propagated - Test: LLM returns malformed JSON (not parseable) → result is
existingunchanged - Test: LLM returns addition not in
required(hallucination) → addition is dropped - Test: LLM returns addition already in
existing→ no duplicate in result - Test: empty
requiredlist →expand_competences()returnsexistingunchanged (no LLM call needed) - Add Google Docstrings to test module
Task 5.4: Extend tests/test_similarity.py
File: tests/test_similarity.py (extended)
Subtasks:
- 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, overallcompetence_score == 0.0 - Required
["Python", "Machine Learning"], candidate["Python", "Machine Learning"]→ both scores 1.0
- Required
- Add tests with
use_bm25_search=True, use_auto_tagging=True(mockedAutoTagger):- Mocked
AutoTagger.expand_competencesinjects"Machine Learning"into["ML"] - Required
["Machine Learning"], candidate["ML"](after expansion) →score > 0
- Mocked
- 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:
- 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 Bcompetence_score - Assert: candidate B
competence_score == 0.0(false-positive eliminated)
- Task requires:
- Add integration test with
use_bm25_search=True, use_auto_tagging=True(mocked LLM via mockedAutoTagger):- Candidate has
["ML", "Python"]; auto-tag adds"Machine Learning"to working list - Required
["Machine Learning", "Python"]→ both scores > 0
- Candidate has
- 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:
- 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_searchanduse_auto_taggingconfig keys
- Describe the three new modules (
Task 6.2: Update docs/semantic_similarity_tradeoffs.md
File: docs/semantic_similarity_tradeoffs.md
Subtasks:
- 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
- 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:
- Add note on score interpretation when
use_bm25_search=true:- A
competence_scoreof 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
- A
- Add note on
use_auto_tagging=true:- Auto-tagging bridges common synonym/abbreviation gaps (e.g. "ML" → "Machine Learning")
- A positive
competence_scorewith 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:
- Add all three new config keys to the config reference section:
matching.similarity.use_bm25_search(boolean, defaultfalse): Enables BM25+RRF lexical competence matchingmatching.similarity.use_auto_tagging(boolean, defaultfalse): Enables LLM pre-expansion before BM25azure_openai.chat_deployment(string, default""): Chat model deployment name for auto-tagging
- Document
AZURE_OPENAI_LLM_API_KEYenvironment variable in the env-vars section
Phase 7: Dependency
Task 7.1: Add rank-bm25 to project dependencies
File: pyproject.toml
Subtasks:
- Add
rank-bm25to[project.dependencies]inpyproject.toml - Run
uv lock(or equivalent) to update the lockfile - Verify
from rank_bm25 import BM25Okapiworks 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
uv run pytest tests/test_bm25.py tests/test_rrf.py tests/test_auto_tagger.py -vuv run pytest -m "not integration" -q- All tests pass
Task 8.2: Run linters
uv run ruff check src/ tests/uv run mypy src/teamlandkarte_mcp/matching/bm25.py src/teamlandkarte_mcp/matching/rrf.py src/teamlandkarte_mcp/matching/auto_tagger.py- Zero errors
Task 8.3: OpenSpec validation
openspec validate add-bm25-rrf-competence-matching --strict- All checks pass
Task 8.4: Confirm legacy files are removed
ls debug_*.py→ no such filesls 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:
- Remove the special-case block that returned
1e-10for zero/negative BM25 scores when token overlap existed (a band-aid for the IDF pathology) - Replace with
max(0.0, float(raw))— ubiquitous terms (negative IDF) simply score 0.0 - Update
Bm25Indexandbm25_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:
- Expose
use_bm25_search: boolas a read-only property (or attribute) onSimilarityEnginesoMatchercan check it without accessing internal config directly
Acceptance:
engine.use_bm25_searchreturns the value ofengine.config.use_bm25_search
Task 9.3: Thread global_index parameter through similarity layer
File: src/teamlandkarte_mcp/matching/similarity.py
Subtasks:
- Add
global_index: Bm25Index | None = Noneparameter tocompute_competence_similarity() - Pass it through to
_bm25_rrf_similarity() - In
_bm25_rrf_similarity(): whenglobal_index is not None, callglobal_index.rank(req)and filter the result list to only entries whose text is inset(candidate); otherwise fall back tobm25_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:
- Before the
for cap in filteredloop: ifself._sim.use_bm25_searchandfilteredis non-empty, collect the deduplicated union of all candidates' competences into a listglobal_corpus, instantiateBm25Index(corpus=global_corpus) - Pass
global_indexto everycompute_competence_similarity()call inside the loop - When
use_bm25_search=False(orfilteredis 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:
test_bm25.py: update small-corpus tests to use ≥ 5-doc corpora so IDF is positive; addtest_bm25_index_rank_filters_to_candidate_subsetandtest_bm25_ubiquitous_term_clamped_to_zerotest_similarity.py: update 4 BM25 tests to pass a 5-docglobal_index; addtest_use_bm25_search_property_reflects_flagandtest_bm25_global_index_filters_to_candidate_subsettest_matcher_integration.py: adduse_bm25_search = Falseto_FakeSimilarityEngine, addglobal_index=Noneparam tocompute_competence_similarity; updatetest_bm25_auto_tag_candidate_matches_after_expansionto use a 5-doc global corpus; addtest_matcher_builds_global_bm25_index_across_all_candidatestest_matching_refinements.py: adduse_bm25_search = Falseandglobal_index=Noneto local_FakeSimstub
Acceptance:
- All 140 tests pass, 2 skipped
Task 9.6: Update docs/architecture.md
File: docs/architecture.md
Subtasks:
- 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
- Update the §9.1 module table row for
bm25.pyto remove the1e-10clamp description and replace withmax(0.0, raw)description - Update the §9.2 data flow diagram to show
match_candidatesbuilding the global index before the per-candidate loop
Acceptance:
- No mention of "per-call index" or
1e-10clamp remains in the docs