from __future__ import annotations from datetime import date from unittest.mock import AsyncMock, MagicMock import pytest from teamlandkarte_mcp.config import MatchingConfig, MatchingThresholds from teamlandkarte_mcp.matching.matcher import Matcher from teamlandkarte_mcp.matching.similarity import SimilarityEngine from teamlandkarte_mcp.models import Capacity, Requirements class _FakeSimilarityEngine: def __init__( self, *, comp_score: float = 1.0, role_score: float = 0.0, ): self._comp_score = comp_score self._role_score = role_score @property def use_bm25_search(self) -> bool: """Always False — the fake engine never builds a global index.""" return False async def compute_competence_similarity( # noqa: ANN001 self, required, _candidate, global_index=None, # noqa: ANN001 ): # Return per required competence entries. return { r: {"score": self._comp_score, "best_match": None, "rationale": ""} for r in required } async def compute_role_similarity( # noqa: ANN001 self, _required_role, _candidate_role, ): return self._role_score @pytest.mark.asyncio async def test_matcher_uses_similarity_engine_scores() -> None: cfg = MatchingConfig( competence_weight=0.8, role_weight=0.2, thresholds=MatchingThresholds(top=0.8, good=0.6, partial=0.4), require_confirmation=True, ) matcher = Matcher(_FakeSimilarityEngine(), cfg) # type: ignore[arg-type] caps = [ Capacity( id=1, owner_name="A", role_name="Dev", role_level=None, begin_date=date(2025, 1, 1), end_date=date(2025, 12, 31), competences=["Python"], ) ] req = Requirements( role_name="Dev", competences=["Python"], date_start=None, date_end=None, description=None, ) result = await matcher.match(caps, req) assert len(result.scored) == 1 assert result.scored[0].competence_score == 1.0 assert result.scored[0].overall_score == 0.8 assert result.scored[0].category == "Top" # --------------------------------------------------------------------------- # BM25 integration: false-positive elimination # --------------------------------------------------------------------------- def _cfg() -> MatchingConfig: return MatchingConfig( competence_weight=0.8, role_weight=0.2, thresholds=MatchingThresholds(top=0.8, good=0.6, partial=0.4), require_confirmation=False, ) def _async_client() -> MagicMock: """Mock AzureOpenAIClient.""" client = MagicMock() client.chat_completion = AsyncMock(return_value='{"similarity": 0.0}') return client def _bm25_engine() -> SimilarityEngine: """Minimal SimilarityEngine with BM25 competence similarity.""" return SimilarityEngine( client=_async_client(), # type: ignore[arg-type] ) @pytest.mark.asyncio async def test_bm25_candidate_with_exact_skills_scores_higher() -> None: """BM25: candidate with exact skills scores higher than JS-only candidate.""" engine = _bm25_engine() matcher = Matcher(engine, _cfg()) caps = [ Capacity( id=1, owner_name="Alice", role_name="ML Engineer", role_level=None, begin_date=None, end_date=None, competences=["Python", "Machine Learning", "Pandas"], ), Capacity( id=2, owner_name="Bob", role_name="Frontend Dev", role_level=None, begin_date=None, end_date=None, competences=["JavaScript", "TypeScript", "Node.js", "Vue.js"], ), ] req = Requirements( role_name="ML Engineer", competences=["Python", "Machine Learning"], date_start=None, date_end=None, description=None, ) result = await matcher.match(caps, req) scored = {s.capacity.id: s for s in result.scored} # Alice has exact matches → competence_score > 0 assert scored[1].competence_score > 0.0 # Bob has zero token overlap → competence_score == 0.0 (false-positive eliminated) assert scored[2].competence_score == 0.0 # Alice scores higher overall assert scored[1].overall_score > scored[2].overall_score @pytest.mark.asyncio async def test_bm25_false_positive_eliminated() -> None: """BM25: JS/TS candidate gets 0.0 when Python/ML is required.""" engine = _bm25_engine() matcher = Matcher(engine, _cfg()) caps = [ Capacity( id=3, owner_name="Carol", role_name="Frontend Dev", role_level=None, begin_date=None, end_date=None, competences=["JavaScript", "TypeScript", "Node.js", "Vue.js", "PWA", "CI/CD"], ), ] req = Requirements( role_name="Data Scientist", competences=["Python", "Machine Learning"], date_start=None, date_end=None, description=None, ) result = await matcher.match(caps, req) assert result.scored[0].competence_score == 0.0 @pytest.mark.asyncio async def test_bm25_auto_tag_candidate_matches_after_expansion() -> None: """BM25 + auto-tag: 'ML' candidate matches 'Machine Learning' via mock tagger. A second candidate "Eve" holds "Machine Learning" as an original skill so it enters the global BM25 corpus. After the mock tagger expands Dave's competences to include "Machine Learning", the global index can score it with positive IDF (N=5 unique skills in the pool). """ mock_tagger = MagicMock() mock_tagger.expand_competences = AsyncMock( return_value=["ML", "Python", "Machine Learning"] ) engine = SimilarityEngine( client=_async_client(), # type: ignore[arg-type] use_auto_tagging=True, auto_tagger=mock_tagger, ) matcher = Matcher(engine, _cfg()) caps = [ Capacity( id=4, owner_name="Dave", role_name="Data Scientist", role_level=None, begin_date=None, end_date=None, competences=["ML", "Python"], ), # Eve's original skills make "Machine Learning" part of the global # BM25 corpus, giving it a stable positive IDF weight. Capacity( id=5, owner_name="Eve", role_name="ML Engineer", role_level=None, begin_date=None, end_date=None, competences=["Machine Learning", "Data Science", "Statistics"], ), ] req = Requirements( role_name="Data Scientist", competences=["Machine Learning", "Python"], date_start=None, date_end=None, description=None, ) result = await matcher.match(caps, req) dave = next(s for s in result.scored if s.capacity.id == 4) # After auto-tag expansion "Machine Learning" is in Dave's working list # and in the global corpus → competence_score > 0 assert dave.competence_score > 0.0 # --------------------------------------------------------------------------- # Global BM25 index construction # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_matcher_builds_global_bm25_index_across_all_candidates() -> None: """Matcher builds one global BM25 index from all filtered candidates' skills. With a global corpus of seven distinct skills the IDF weights are stable and each candidate is scored against the *same* index. Two candidates with non-overlapping skill sets must receive different competence scores: the one whose skills share tokens with the required competence scores above zero, the other must score exactly zero. This verifies the fix for the per-person IDF pathology: if a separate index were built per-person the candidate with only one skill would get IDF ≤ 0 and score 0.0 incorrectly. """ engine = _bm25_engine() matcher = Matcher(engine, _cfg()) caps = [ # Frank: single relevant skill — previously broken by per-person N=1 Capacity( id=10, owner_name="Frank", role_name="Backend Dev", role_level=None, begin_date=None, end_date=None, competences=["Python"], ), # Grace: unrelated skills only Capacity( id=11, owner_name="Grace", role_name="Designer", role_level=None, begin_date=None, end_date=None, competences=["Figma", "Sketch", "CSS", "HTML", "Illustrator", "InDesign"], ), ] req = Requirements( role_name="Backend Dev", competences=["Python"], date_start=None, date_end=None, description=None, ) result = await matcher.match(caps, req) scored = {s.capacity.id: s for s in result.scored} # With a global corpus of 7 unique skills, IDF("python") > 0. # Frank's single-skill corpus would have given IDF < 0 (per-person bug). assert scored[10].competence_score > 0.0, ( "Frank should score > 0: global IDF fixes the per-person N=1 pathology" ) # Grace has no token overlap with "Python" → score exactly 0.0 assert scored[11].competence_score == 0.0 # Frank ranks above Grace assert scored[10].overall_score > scored[11].overall_score