"""The ``aeat_terminology_search`` tool: look up taxpayer-facing tax vocabulary.
ADR R3's grounding surface has a second half: the bundled Terminology Handbook,
the ratified taxpayer/operator vocabulary. This tool lets the model resolve a
plain-language term ("prorrata", "recargo de equivalencia") to its preferred
label, short description, and concept id, so the assistant explains a concept in
the taxpayer's own words rather than inventing a definition. Only
``approved``-lifecycle concepts are searched (the taxpayer-facing set), per the
``glossary-concepts-are-taxpayer-facing`` rule the application service already
enforces.
Like ``_corpus_tools`` / ``_harness_tools``, this module is SDK-independent pure
functions over typed models; :func:`build_terminology_search_tool` lazily adapts
onto the MCP SDK's ``Tool`` type so the module imports (and the server refuses
gracefully) without the ``aeat-cli[agent]`` extra. The search itself is owned by the
application service (:func:`~application.corpus_search.search_terminology`),
consumed through the package facade per ``service-imports-via-top-level-reexports``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
from ...application.corpus_search import TerminologyHit, search_terminology
if TYPE_CHECKING:
# Typing-only: the MCP SDK is an optional runtime dependency (``aeat-cli[agent]``);
# the real import stays deferred to inside the function body below.
from mcp.types import Tool
#: The terminology tool's MCP name (the ``terminology.search`` verb).
TERMINOLOGY_SEARCH_TOOL = "aeat_terminology_search"
_DEFAULT_LIMIT = 8
_MAX_LIMIT = 25
_STRICT_FROZEN = ConfigDict(frozen=True, strict=True, validate_assignment=True, extra="forbid")
[docs]
class TerminologyResultRow(BaseModel):
"""One terminology hit surfaced to the model."""
model_config = _STRICT_FROZEN
concept_id: str = Field(min_length=1)
preferred_label: str = Field(min_length=1)
short_description: str = ""
matched_on: str = Field(min_length=1)
[docs]
class TerminologySearchPayload(BaseModel):
"""The terminology tool's structured result."""
model_config = _STRICT_FROZEN
query: str = Field(min_length=1)
results: tuple[TerminologyResultRow, ...] = ()
[docs]
def terminology_payload_from_hits(query: str, hits: tuple[TerminologyHit, ...]) -> TerminologySearchPayload:
"""Map ranked terminology hits to the tool's typed payload.
Returns:
A :class:`TerminologySearchPayload`.
"""
rows = tuple(
TerminologyResultRow(
concept_id=hit.concept_id,
preferred_label=hit.preferred_label,
short_description=hit.short_description,
matched_on=hit.matched_on,
)
for hit in hits
)
return TerminologySearchPayload(query=query, results=rows)
[docs]
def build_terminology_search_payload(
query: str,
*,
locale: str = "es",
limit: int = _DEFAULT_LIMIT,
) -> TerminologySearchPayload:
"""Run terminology search for ``query`` and return the tool payload.
Returns:
A :class:`TerminologySearchPayload`.
"""
hits = search_terminology(query, locale=locale, limit=limit)
return terminology_payload_from_hits(query, hits)
[docs]
def render_terminology_search_text(payload: TerminologySearchPayload) -> str:
"""Render the payload as markdown for the tool's text content."""
if not payload.results:
return f"No terminology concept matches '{payload.query}'."
lines = [f"# terminology matches for '{payload.query}'", ""]
for index, row in enumerate(payload.results, start=1):
lines.append(f"{index}. {row.preferred_label} ({row.concept_id})")
if row.short_description:
lines.append(f" {row.short_description}")
return "\n".join(lines)
__all__ = [
"TERMINOLOGY_SEARCH_TOOL",
"TerminologyResultRow",
"TerminologySearchPayload",
"build_terminology_search_payload",
"build_terminology_search_tool",
"render_terminology_search_text",
"terminology_payload_from_hits",
]