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.
178 lines
5.3 KiB
Python
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"
|