Files
Orchestrator/bahn/teamlandkarte-mcp/tests/test_batch_unit.py
T
ankn a5f8fb49ab 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.
2026-06-30 20:39:52 +02:00

178 lines
5.3 KiB
Python

"""Unit tests for LLM batch-matching scenarios.
Covers:
- Empty input returns immediately without LLM calls
- All errors: no exception, complete error list
- Default max_concurrency value is 5
- Single error does not affect other candidates
Anforderungen: 4.2, 4.3, 4.4, 5.3
"""
from __future__ import annotations
import asyncio
import json
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
from teamlandkarte_mcp.matching.profiles import TaskProfile
from teamlandkarte_mcp.models import Capacity
_CATEGORIES = ("Top", "Good", "Partial", "Low", "Irrelevant")
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _StubDb:
def batch_get_capacity_descriptions(self, capacity_ids):
return {str(i): None for i in capacity_ids}
def batch_get_capacity_certificates(self, capacity_ids):
return {str(i): [] for i in capacity_ids}
def batch_get_capacity_references(self, capacity_ids):
return {str(i): [] for i in capacity_ids}
class _StaticLlm:
"""Always returns the same response or raises."""
def __init__(self, response=None, exc=None):
self._response = response
self._exc = exc
self.call_count = 0
async def chat_completion(self, system, user):
self.call_count += 1
if self._exc:
raise self._exc
return self._response
class _FailOneLlm:
"""Fails on a specific call index, succeeds on others."""
def __init__(self, fail_index: int):
self._fail_index = fail_index
self._call_index = 0
async def chat_completion(self, system, user):
idx = self._call_index
self._call_index += 1
if idx == self._fail_index:
raise AzureAPIError("simulated failure")
return json.dumps({"category": "Top", "rationale": "ok"})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_capacities(n: int) -> list[Capacity]:
return [
Capacity(
id=i + 1,
owner_name=f"owner{i + 1}",
role_name="Engineer",
role_level=None,
begin_date=None,
end_date=None,
competences=[],
)
for i in range(n)
]
def _task_profile() -> TaskProfile:
return TaskProfile(id="t1", title="Task", description="Desc", skills=[])
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_empty_input_returns_immediately_without_llm_calls():
"""Empty capacities list returns empty result, no LLM calls made."""
llm = _StaticLlm(response=json.dumps({"category": "Top", "rationale": "x"}))
matcher = LlmFulltextMatcher(db=_StubDb(), client=llm)
result = asyncio.run(
matcher.match_capacities(task_profile=_task_profile(), capacities=[])
)
# All 5 categories present but empty
assert set(result.by_category.keys()) == set(_CATEGORIES)
for cat in _CATEGORIES:
assert result.by_category[cat] == []
# No errors
assert result.errors == []
# No LLM calls were made
assert llm.call_count == 0
def test_all_errors_no_exception_complete_error_list():
"""When all LLM calls fail, no exception is thrown and errors list is complete."""
n = 5
llm = _StaticLlm(exc=AzureAPIError("total failure"))
matcher = LlmFulltextMatcher(db=_StubDb(), client=llm)
capacities = _make_capacities(n)
result = asyncio.run(
matcher.match_capacities(task_profile=_task_profile(), capacities=capacities)
)
# All categories empty
for cat in _CATEGORIES:
assert result.by_category[cat] == []
# All candidates appear in errors
assert len(result.errors) == n
error_ids = {e.item_id for e in result.errors}
expected_ids = {str(i + 1) for i in range(n)}
assert error_ids == expected_ids
def test_default_max_concurrency_is_five():
"""LlmFulltextMatcher without explicit max_concurrency uses 5."""
llm = _StaticLlm()
matcher = LlmFulltextMatcher(db=_StubDb(), client=llm)
assert matcher._max_concurrency == 5
def test_single_error_does_not_affect_others():
"""One failing LLM call does not prevent other candidates from being categorized."""
n = 5
fail_index = 2 # The 3rd call (index 2) will fail
llm = _FailOneLlm(fail_index=fail_index)
matcher = LlmFulltextMatcher(db=_StubDb(), client=llm)
capacities = _make_capacities(n)
result = asyncio.run(
matcher.match_capacities(task_profile=_task_profile(), capacities=capacities)
)
# Exactly one error
assert len(result.errors) == 1
# The other 4 are categorized (all in "Top" per _FailOneLlm)
categorized_items = []
for items in result.by_category.values():
categorized_items.extend(items)
assert len(categorized_items) == n - 1
# Total partition is preserved
assert len(categorized_items) + len(result.errors) == n
# All categorized items are in "Top"
for item in categorized_items:
assert item.category == "Top"