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.
312 lines
9.5 KiB
Python
312 lines
9.5 KiB
Python
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
|