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,230 @@
|
||||
# Feature: llm-fulltext-matching, Property 9: matching_method-Validierung lehnt unbekannte Werte ab
|
||||
"""Property test for ``matching_method`` validation.
|
||||
|
||||
The MCP tools ``find_matching_capacities`` and ``find_matching_tasks``
|
||||
must reject any ``matching_method`` value whose ``strip().lower()``
|
||||
representation is neither ``"score"`` nor ``"llm_fulltext"``. The
|
||||
rejection must:
|
||||
|
||||
1. Produce an error message that mentions both allowed values
|
||||
(``score`` and ``llm_fulltext``).
|
||||
2. Avoid touching the database (no capacity loading) and the LLM (no
|
||||
``chat_completion`` call). The validation must short-circuit BEFORE
|
||||
any side-effect.
|
||||
|
||||
The fake database and the fake ``chat_completion`` track call counts;
|
||||
both counters must remain zero for every Hypothesis-generated invalid
|
||||
input.
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
class _CountingDB:
|
||||
"""Fake DB that counts how often ``get_all_capacities_with_competences``
|
||||
is invoked. Any non-zero counter after a rejected validation is a bug.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.capacity_calls = 0
|
||||
self.capacity_by_id_calls = 0
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
self.capacity_calls += 1
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_capacity_by_id(self, capacity_id): # pragma: no cover
|
||||
self.capacity_by_id_calls += 1
|
||||
return None
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _build_test_server(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> tuple[Any, _CountingDB, dict[str, int]]:
|
||||
"""Construct a server backed by counting DB and LLM doubles."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
fake_db = _CountingDB()
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
|
||||
)
|
||||
|
||||
llm_calls = {"count": 0}
|
||||
|
||||
async def _fake_chat_completion(self, system, user): # noqa: ARG001
|
||||
llm_calls["count"] += 1
|
||||
return '{"category":"Top","rationale":"r"}'
|
||||
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
_fake_chat_completion,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
return srv, fake_db, llm_calls
|
||||
|
||||
|
||||
# Reject anything that, after trimming and lower-casing, is one of the
|
||||
# canonical values (those are valid inputs and would not exercise the
|
||||
# error path) or the empty string. The empty/whitespace-only string is
|
||||
# treated by the server as "no value provided" (it then falls back to
|
||||
# the configured default), so it is not part of the invalid-input space
|
||||
# this property test targets. Hypothesis is otherwise free to generate
|
||||
# any text.
|
||||
def _is_invalid_method(value: str) -> bool:
|
||||
norm = value.strip().lower()
|
||||
if norm == "":
|
||||
return False
|
||||
return norm not in ("score", "llm_fulltext")
|
||||
|
||||
|
||||
_INVALID_METHOD = st.text(max_size=20).filter(_is_invalid_method)
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=50,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(value=_INVALID_METHOD)
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_matching_method_is_rejected_without_side_effects(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Property 9: invalid matching_method values are rejected verbatim.
|
||||
|
||||
The error message must list both allowed values; the database and the
|
||||
LLM mock must not be touched.
|
||||
"""
|
||||
srv, fake_db, llm_calls = _build_test_server(monkeypatch, tmp_path)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": value,
|
||||
},
|
||||
)
|
||||
|
||||
# The error message references both allowed values.
|
||||
assert "score" in res, (
|
||||
f"missing 'score' in error response for {value!r}: {res!r}"
|
||||
)
|
||||
assert "llm_fulltext" in res, (
|
||||
f"missing 'llm_fulltext' in error response for {value!r}: {res!r}"
|
||||
)
|
||||
|
||||
# The response must signal an error (not a successful search).
|
||||
lowered = res.lower()
|
||||
assert ("error" in lowered) or ("invalid" in lowered), (
|
||||
f"response is not an error for {value!r}: {res!r}"
|
||||
)
|
||||
|
||||
# No DB/LLM access for the rejected validation path.
|
||||
assert fake_db.capacity_calls == 0, (
|
||||
f"DB capacity loader was invoked for invalid value {value!r}"
|
||||
)
|
||||
assert llm_calls["count"] == 0, (
|
||||
f"LLM was invoked for invalid value {value!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user