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.
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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
|
||||
Reference in New Issue
Block a user