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.
135 lines
4.4 KiB
Python
135 lines
4.4 KiB
Python
# Feature: llm-fulltext-matching, Property 8: Ungekürzte Rationale wird persistiert
|
|
"""Property-based test verifying that long rationales are persisted verbatim.
|
|
|
|
The MCP server stores LLM matcher results into ``SearchCache`` by merging
|
|
the source ``Capacity``/``Task`` payload with ``category`` and
|
|
``rationale`` fields from each :class:`LlmFulltextItem` (see
|
|
``mcp_server.find_matching_capacities``). The persistence step is a
|
|
simple pass-through:
|
|
|
|
merged = dict(it.raw)
|
|
merged["rationale"] = it.rationale
|
|
|
|
That means the LLM-returned rationale on the
|
|
:class:`~teamlandkarte_mcp.matching.llm_fulltext_matcher.LlmFulltextItem`
|
|
must already be byte-for-byte identical to the LLM payload. This test
|
|
exercises the matcher directly with a fake LLM that echoes long
|
|
rationales (>= 300 chars) and asserts the resulting item carries the
|
|
same string back.
|
|
|
|
Testing at the matcher level is sufficient evidence for requirement 8.6:
|
|
the cache pass-through cannot introduce truncation if the source item
|
|
already holds the unmodified text. The table-rendering helper
|
|
``_format_rationale_for_table`` is covered separately by the Property 7
|
|
test (``test_format_rationale_pbt.py``).
|
|
|
|
**Validates: Requirements 8.6**
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from typing import Any
|
|
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
|
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
|
from teamlandkarte_mcp.models import Capacity
|
|
|
|
|
|
# Exclude characters that would be mangled by JSON serialization or that
|
|
# Hypothesis would otherwise generate (lone surrogates, control chars,
|
|
# line/paragraph separators). The remaining alphabet is wide enough to
|
|
# exercise multi-byte text while staying round-trippable through
|
|
# ``json.dumps``/``json.loads``.
|
|
_RATIONALE_STRATEGY = st.text(
|
|
alphabet=st.characters(
|
|
blacklist_categories=("Cs", "Cc", "Zl", "Zp"),
|
|
),
|
|
min_size=300,
|
|
max_size=400,
|
|
)
|
|
|
|
|
|
class _StubDb:
|
|
"""Minimal ``DBClient`` double returning empty extras for every id."""
|
|
|
|
def batch_get_capacity_descriptions(
|
|
self, capacity_ids: list[Any]
|
|
) -> dict[str, str | None]:
|
|
return {str(i): None for i in capacity_ids}
|
|
|
|
def batch_get_capacity_certificates(
|
|
self, capacity_ids: list[Any]
|
|
) -> dict[str, list[str]]:
|
|
return {str(i): [] for i in capacity_ids}
|
|
|
|
def batch_get_capacity_references(
|
|
self, capacity_ids: list[Any]
|
|
) -> dict[str, list[dict]]:
|
|
return {str(i): [] for i in capacity_ids}
|
|
|
|
|
|
class _EchoLlm:
|
|
"""Returns the supplied rationale wrapped in a ``Top`` JSON payload."""
|
|
|
|
def __init__(self, rationale: str) -> None:
|
|
self._rationale = rationale
|
|
|
|
async def chat_completion(self, system: str, user: str) -> str:
|
|
return json.dumps(
|
|
{"category": "Top", "rationale": self._rationale},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
|
|
def _capacity() -> Capacity:
|
|
return Capacity(
|
|
id=1,
|
|
owner_name="o",
|
|
role_name="R",
|
|
role_level=None,
|
|
begin_date=None,
|
|
end_date=None,
|
|
competences=[],
|
|
)
|
|
|
|
|
|
@settings(max_examples=100, deadline=None)
|
|
@given(rationale=_RATIONALE_STRATEGY)
|
|
def test_long_rationale_is_persisted_unchanged(rationale: str) -> None:
|
|
"""Property 8: matcher returns the rationale verbatim, length >= 300.
|
|
|
|
The matcher's per-item ``rationale`` is what the MCP server merges
|
|
into the persisted SearchCache payload, so equality at this layer
|
|
proves the rationale survives the pass-through unchanged.
|
|
"""
|
|
matcher = LlmFulltextMatcher(
|
|
db=_StubDb(),
|
|
client=_EchoLlm(rationale),
|
|
)
|
|
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
|
|
|
result = asyncio.run(
|
|
matcher.match_capacities(
|
|
task_profile=task_profile,
|
|
capacities=[_capacity()],
|
|
)
|
|
)
|
|
|
|
assert result.errors == [], f"unexpected errors: {result.errors}"
|
|
items = result.by_category["Top"]
|
|
assert len(items) == 1, "expected exactly one Top item"
|
|
|
|
item = items[0]
|
|
assert item.category == "Top"
|
|
# The rationale must be bit-identical to what the LLM returned, even
|
|
# though the input is well above the 280-char display limit.
|
|
assert item.rationale == rationale, (
|
|
"rationale was modified between LLM response and matcher item"
|
|
)
|
|
assert len(item.rationale) >= 300
|