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,177 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user