# 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