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.
194 lines
6.6 KiB
Python
194 lines
6.6 KiB
Python
# Feature: remove-embedding-competence-similarity, Property 2: Matcher baut BM25-Index bedingungslos
|
|
"""Property-based tests for unconditional BM25 index building in Matcher.
|
|
|
|
For any non-empty list of filtered candidates with competences, the Matcher
|
|
shall always build a global BM25 index and use it for competence scoring,
|
|
producing results where candidates with no token overlap receive score 0.0.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import pytest
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from teamlandkarte_mcp.config import MatchingConfig
|
|
from teamlandkarte_mcp.matching.matcher import Matcher
|
|
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
|
|
from teamlandkarte_mcp.models import Capacity, Requirements
|
|
|
|
|
|
class _DummyClient:
|
|
"""Minimal stub for AzureOpenAIClient."""
|
|
|
|
async def chat_completion(self, system: str, user: str) -> str:
|
|
return '{"similarity": 0.5}'
|
|
|
|
|
|
def _make_matcher() -> Matcher:
|
|
engine = SimilarityEngine(client=_DummyClient()) # type: ignore[arg-type]
|
|
cfg = MatchingConfig()
|
|
return Matcher(similarity=engine, cfg=cfg)
|
|
|
|
|
|
# Strategy: ASCII-only letters to avoid Unicode tokenization edge cases.
|
|
# The BM25 tokenizer lowercases then splits on [\W_]+. Some Unicode letters
|
|
# produce non-word characters when lowercased (e.g. İ -> i + combining dot),
|
|
# which would create unexpected token boundaries and shared tokens.
|
|
_ascii_letters = st.text(
|
|
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz"),
|
|
min_size=3,
|
|
max_size=15,
|
|
)
|
|
|
|
_competence_list = st.lists(_ascii_letters, min_size=1, max_size=4)
|
|
|
|
|
|
@st.composite
|
|
def _capacity_strategy(draw: st.DrawFn) -> Capacity:
|
|
"""Generate a Capacity with competences prefixed by 'cand'."""
|
|
comps = draw(_competence_list)
|
|
prefixed = [f"cand{c}" for c in comps]
|
|
return Capacity(
|
|
id=draw(st.integers(min_value=1, max_value=10000)),
|
|
owner_name="TestOwner",
|
|
role_name="TestRole",
|
|
role_level=None,
|
|
begin_date=date(2025, 1, 1),
|
|
end_date=date(2025, 12, 31),
|
|
competences=prefixed,
|
|
)
|
|
|
|
|
|
# **Validates: Requirements 2.6**
|
|
@settings(max_examples=100)
|
|
@given(
|
|
capacities=st.lists(_capacity_strategy(), min_size=1, max_size=5),
|
|
required_comps=_competence_list,
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_matcher_builds_bm25_index_unconditionally(
|
|
capacities: list[Capacity],
|
|
required_comps: list[str],
|
|
) -> None:
|
|
"""Property 2: Matcher builds BM25 index unconditionally for all filtered
|
|
candidates.
|
|
|
|
For any non-empty list of filtered candidates, the Matcher always builds
|
|
a global BM25 index. When required competences have no token overlap with
|
|
any candidate competence, the competence score must be 0.0.
|
|
"""
|
|
# Prefix required competences with "req" to guarantee disjoint tokens.
|
|
# Since all generated strings are pure ASCII lowercase letters, and
|
|
# "req" != "cand", the tokens "req<x>" and "cand<y>" can never overlap.
|
|
disjoint_required = [f"req{c}" for c in required_comps]
|
|
|
|
requirements = Requirements(
|
|
role_name="TestRole",
|
|
competences=disjoint_required,
|
|
date_start=date(2025, 1, 1),
|
|
date_end=date(2025, 12, 31),
|
|
)
|
|
|
|
matcher = _make_matcher()
|
|
result = await matcher.match(capacities, requirements)
|
|
|
|
# All candidates should be in the result (dates overlap)
|
|
assert len(result.scored) == len(capacities)
|
|
|
|
# Since required competences (req*) have no token overlap with
|
|
# candidate competences (cand*), all competence scores must be 0.0
|
|
for scored in result.scored:
|
|
assert scored.competence_score == 0.0, (
|
|
f"Expected competence_score 0.0 for capacity {scored.capacity.id}, "
|
|
f"got {scored.competence_score}. "
|
|
f"Required: {disjoint_required}, "
|
|
f"Candidate: {scored.capacity.competences}"
|
|
)
|
|
assert set(scored.missing_competences) == set(disjoint_required), (
|
|
f"Expected all required competences to be missing, "
|
|
f"got missing={scored.missing_competences}"
|
|
)
|
|
|
|
|
|
@st.composite
|
|
def _capacities_with_unique_token(draw: st.DrawFn) -> list[Capacity]:
|
|
"""Generate 3 capacities where the first has a unique competence.
|
|
|
|
BM25 IDF = log((N - df + 0.5) / (df + 0.5)). With N=2 and df=1,
|
|
IDF = log(1) = 0. We need N >= 3 with df=1 for IDF > 0.
|
|
|
|
We generate 3 capacities with 3 distinct competences in the global
|
|
corpus. The unique token appears only in cap1 (df=1, N=3 → IDF > 0).
|
|
"""
|
|
base = draw(_ascii_letters)
|
|
other = f"other{draw(_ascii_letters)}"
|
|
unique_token = f"zzuniq{draw(_ascii_letters)}"
|
|
cap1 = Capacity(
|
|
id=1,
|
|
owner_name="TestOwner",
|
|
role_name="TestRole",
|
|
role_level=None,
|
|
begin_date=date(2025, 1, 1),
|
|
end_date=date(2025, 12, 31),
|
|
competences=[base, unique_token],
|
|
)
|
|
cap2 = Capacity(
|
|
id=2,
|
|
owner_name="TestOwner",
|
|
role_name="TestRole",
|
|
role_level=None,
|
|
begin_date=date(2025, 1, 1),
|
|
end_date=date(2025, 12, 31),
|
|
competences=[base],
|
|
)
|
|
cap3 = Capacity(
|
|
id=3,
|
|
owner_name="TestOwner",
|
|
role_name="TestRole",
|
|
role_level=None,
|
|
begin_date=date(2025, 1, 1),
|
|
end_date=date(2025, 12, 31),
|
|
competences=[other],
|
|
)
|
|
return [cap1, cap2, cap3]
|
|
|
|
|
|
@settings(max_examples=50)
|
|
@given(capacities=_capacities_with_unique_token())
|
|
@pytest.mark.asyncio
|
|
async def test_matcher_scores_nonzero_for_token_overlap(
|
|
capacities: list[Capacity],
|
|
) -> None:
|
|
"""Property 2 (supplementary): When the BM25 index is built and a required
|
|
competence shares tokens with a candidate, the score is non-zero.
|
|
|
|
This confirms the index is actually built and used for scoring (not
|
|
skipped). BM25 IDF is positive when a term appears in fewer than half
|
|
the corpus documents. With 3+ corpus docs and df=1, IDF > 0.
|
|
"""
|
|
unique_comp = capacities[0].competences[1]
|
|
|
|
requirements = Requirements(
|
|
role_name="TestRole",
|
|
competences=[unique_comp],
|
|
date_start=date(2025, 1, 1),
|
|
date_end=date(2025, 12, 31),
|
|
)
|
|
|
|
matcher = _make_matcher()
|
|
result = await matcher.match(capacities, requirements)
|
|
|
|
assert len(result.scored) == len(capacities)
|
|
|
|
# Cap1 must have non-zero competence score (it has the unique token)
|
|
first_cap_scored = next(
|
|
s for s in result.scored if s.capacity.id == capacities[0].id
|
|
)
|
|
assert first_cap_scored.competence_score > 0.0, (
|
|
f"Expected non-zero competence score for unique token match, "
|
|
f"got {first_cap_scored.competence_score}. "
|
|
f"Required: [{unique_comp}], Candidate: {capacities[0].competences}"
|
|
)
|