Add deterministic article search to MCP
This commit is contained in:
@@ -8,6 +8,7 @@ from rollcalc_mcp_tools import (
|
|||||||
analyze_transport_capacity_result,
|
analyze_transport_capacity_result,
|
||||||
calculate_roll_diameter_result,
|
calculate_roll_diameter_result,
|
||||||
get_article_result,
|
get_article_result,
|
||||||
|
search_articles_result,
|
||||||
)
|
)
|
||||||
from transport_calculation import TransportPresetKey
|
from transport_calculation import TransportPresetKey
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ def create_server() -> Any:
|
|||||||
"""Resolve RollCalc article master data. article_number is text (including leading zeroes); optional article_name_hint preserves existing conflict and ambiguity checks."""
|
"""Resolve RollCalc article master data. article_number is text (including leading zeroes); optional article_name_hint preserves existing conflict and ambiguity checks."""
|
||||||
return get_article_result(article_number, article_name_hint)
|
return get_article_result(article_number, article_name_hint)
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def search_articles(query: str) -> dict[str, Any]:
|
||||||
|
"""Use search_articles when the user identifies a RollCalc product by name, family, designation, or descriptive article text instead of an exact article number. Do not guess or invent an article number: results are deterministic candidates from the RollCalc article master data. For an exact article number, use get_article -> calculate_roll_diameter -> optionally analyze_transport_capacity. For a product name, use search_articles first. Only when total_matches is exactly 1 may you use that candidate's exact article_number and continue through get_article and the requested workflow. When multiple candidates are returned, do NOT select the first or highest-ranked candidate and do not calculate yet: present relevant candidates with their article numbers, distinguishing names, and production_site when supplied; ask the user which article number is intended, then stop until they select it. Ranking/order expresses relevance only and never authorizes automatic selection. If no candidate is returned, say that no matching RollCalc article was found, ask for a more specific designation or article number, and do not invent an article. production_site=Malaysia is descriptive metadata only: it does not imply Bentofix, Bento 2, or any production machine."""
|
||||||
|
return search_articles_result(query)
|
||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def calculate_roll_diameter(
|
def calculate_roll_diameter(
|
||||||
roll_length_m: float | None = None,
|
roll_length_m: float | None = None,
|
||||||
|
|||||||
@@ -251,6 +251,56 @@ class ArticleRepository:
|
|||||||
|
|
||||||
return {"status": "not_requested", "article": None}
|
return {"status": "not_requested", "article": None}
|
||||||
|
|
||||||
|
def search(self, query: str, *, limit: int = 20) -> dict[str, Any]:
|
||||||
|
"""Return deterministic article-discovery candidates without resolving one."""
|
||||||
|
if not isinstance(query, str):
|
||||||
|
return {
|
||||||
|
"status": "invalid_query",
|
||||||
|
"query": None,
|
||||||
|
"total_matches": 0,
|
||||||
|
"candidates": [],
|
||||||
|
}
|
||||||
|
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
|
||||||
|
raise ValueError("limit must be a positive integer")
|
||||||
|
|
||||||
|
query = query.strip()
|
||||||
|
query_key = _canonical_name(query)
|
||||||
|
if not query_key:
|
||||||
|
return {
|
||||||
|
"status": "search_not_found",
|
||||||
|
"query": query,
|
||||||
|
"total_matches": 0,
|
||||||
|
"candidates": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
query_tokens = _token_counts(query_key)
|
||||||
|
matches: list[tuple[bool, int, dict[str, Any]]] = []
|
||||||
|
for article in self.articles:
|
||||||
|
name_key = _canonical_name(str(article.get("name", "")))
|
||||||
|
name_tokens = _token_counts(name_key)
|
||||||
|
if not _tokens_contained(query_tokens, name_tokens):
|
||||||
|
continue
|
||||||
|
unmatched_tokens = _unmatched_token_count(name_tokens, query_tokens)
|
||||||
|
matches.append((name_key == query_key, unmatched_tokens, article))
|
||||||
|
|
||||||
|
matches.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
not item[0],
|
||||||
|
item[1],
|
||||||
|
str(item[2].get("nr", "")),
|
||||||
|
str(item[2].get("name", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "search_results" if matches else "search_not_found",
|
||||||
|
"query": query,
|
||||||
|
"total_matches": len(matches),
|
||||||
|
"candidates": [
|
||||||
|
_article_search_candidate(article)
|
||||||
|
for _, _, article in matches[:limit]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
def _matching_names(self, article_name_hint: str) -> list[dict[str, Any]]:
|
def _matching_names(self, article_name_hint: str) -> list[dict[str, Any]]:
|
||||||
hint_keys = _article_name_keys(article_name_hint)
|
hint_keys = _article_name_keys(article_name_hint)
|
||||||
if not hint_keys:
|
if not hint_keys:
|
||||||
@@ -351,6 +401,31 @@ def _canonical_name(value: str) -> str:
|
|||||||
return " ".join(compacted)
|
return " ".join(compacted)
|
||||||
|
|
||||||
|
|
||||||
|
def _token_counts(value: str) -> dict[str, int]:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for token in value.split():
|
||||||
|
counts[token] = counts.get(token, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens_contained(
|
||||||
|
query_tokens: dict[str, int], candidate_tokens: dict[str, int]
|
||||||
|
) -> bool:
|
||||||
|
return all(
|
||||||
|
candidate_tokens.get(token, 0) >= count
|
||||||
|
for token, count in query_tokens.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unmatched_token_count(
|
||||||
|
candidate_tokens: dict[str, int], query_tokens: dict[str, int]
|
||||||
|
) -> int:
|
||||||
|
return sum(
|
||||||
|
max(0, count - query_tokens.get(token, 0))
|
||||||
|
for token, count in candidate_tokens.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _article_name_keys(value: str) -> set[str]:
|
def _article_name_keys(value: str) -> set[str]:
|
||||||
keys = {_canonical_name(value)}
|
keys = {_canonical_name(value)}
|
||||||
without_dimensions = re.sub(
|
without_dimensions = re.sub(
|
||||||
@@ -391,6 +466,22 @@ def _article_candidate(article: dict[str, Any]) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _article_search_candidate(article: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = str(article.get("name", ""))
|
||||||
|
number = str(article.get("nr", ""))
|
||||||
|
return {
|
||||||
|
"article_number": number,
|
||||||
|
"name": " ".join(name.split()),
|
||||||
|
"width_m": _article_width(name),
|
||||||
|
"production_site": article_production_site(number),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def article_production_site(article_number: str) -> str | None:
|
||||||
|
"""Return production-site metadata established by article-number rules."""
|
||||||
|
return "Malaysia" if article_number.startswith("8") else None
|
||||||
|
|
||||||
|
|
||||||
def _article_width(name: str) -> float | None:
|
def _article_width(name: str) -> float | None:
|
||||||
patterns = (
|
patterns = (
|
||||||
r"(?<!\d)(\d{1,2}[,.]\d{1,3})\s*[x×]\s*<?\s*\d+(?:[,.]\d+)?\s*m\b",
|
r"(?<!\d)(\d{1,2}[,.]\d{1,3})\s*[x×]\s*<?\s*\d+(?:[,.]\d+)?\s*m\b",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from roll_calculation import (
|
from roll_calculation import (
|
||||||
|
ArticleRepository,
|
||||||
calculate_roll,
|
calculate_roll,
|
||||||
get_article,
|
get_article,
|
||||||
transport_roll_inputs_from_roll_calculation,
|
transport_roll_inputs_from_roll_calculation,
|
||||||
@@ -20,6 +21,11 @@ def get_article_result(
|
|||||||
return get_article(article_number, article_name_hint)
|
return get_article(article_number, article_name_hint)
|
||||||
|
|
||||||
|
|
||||||
|
def search_articles_result(query: str) -> dict[str, Any]:
|
||||||
|
"""Delegate article discovery to the shared article repository."""
|
||||||
|
return ArticleRepository.load().search(query)
|
||||||
|
|
||||||
|
|
||||||
def calculate_roll_diameter_result(
|
def calculate_roll_diameter_result(
|
||||||
*,
|
*,
|
||||||
article_number: str | None = None,
|
article_number: str | None = None,
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import mcp_server
|
import mcp_server
|
||||||
from roll_calculation import calculate_roll, get_article
|
from roll_calculation import ArticleRepository, calculate_roll, get_article
|
||||||
from rollcalc_mcp_tools import (
|
from rollcalc_mcp_tools import (
|
||||||
analyze_transport_capacity_result,
|
analyze_transport_capacity_result,
|
||||||
calculate_roll_diameter_result,
|
calculate_roll_diameter_result,
|
||||||
get_article_result,
|
get_article_result,
|
||||||
|
search_articles_result,
|
||||||
)
|
)
|
||||||
from transport_calculation import analyze_transport, transport_presets
|
from transport_calculation import analyze_transport, transport_presets
|
||||||
|
|
||||||
@@ -39,6 +40,34 @@ class McpAdapterTests(unittest.TestCase):
|
|||||||
get_article(**arguments),
|
get_article(**arguments),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_search_articles_delegates_to_domain_and_preserves_candidates(self):
|
||||||
|
query = "Bfix NSP 4900, 5,00 x 40 m"
|
||||||
|
|
||||||
|
result = search_articles_result(query)
|
||||||
|
|
||||||
|
self.assertEqual(result, ArticleRepository.load().search(query))
|
||||||
|
self.assertEqual(result["status"], "search_results")
|
||||||
|
self.assertEqual(
|
||||||
|
[candidate["article_number"] for candidate in result["candidates"]],
|
||||||
|
["180205", "8180205", "182815", "206900"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["candidates"][1]["production_site"], "Malaysia"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_articles_preserves_ambiguous_and_not_found_domain_results(self):
|
||||||
|
ambiguous = search_articles_result("Bentofix NSP 4900")
|
||||||
|
missing = search_articles_result("not a RollCalc product")
|
||||||
|
|
||||||
|
self.assertGreater(ambiguous["total_matches"], 1)
|
||||||
|
self.assertGreater(len(ambiguous["candidates"]), 1)
|
||||||
|
self.assertEqual(missing, {
|
||||||
|
"status": "search_not_found",
|
||||||
|
"query": "not a RollCalc product",
|
||||||
|
"total_matches": 0,
|
||||||
|
"candidates": [],
|
||||||
|
})
|
||||||
|
|
||||||
def test_calculate_roll_diameter_delegates_for_normal_weighted_and_invalid_cases(self):
|
def test_calculate_roll_diameter_delegates_for_normal_weighted_and_invalid_cases(self):
|
||||||
for arguments in (
|
for arguments in (
|
||||||
{
|
{
|
||||||
@@ -111,7 +140,7 @@ class McpAdapterTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["analysis"]["final_rolls"], 24)
|
self.assertEqual(result["analysis"]["final_rolls"], 24)
|
||||||
self.assertEqual(result["analysis"]["limiting"]["name"], "Weight")
|
self.assertEqual(result["analysis"]["limiting"]["name"], "Weight")
|
||||||
|
|
||||||
def test_server_registers_exactly_the_three_stdio_poc_tools(self):
|
def test_server_registers_the_existing_tools_and_article_search(self):
|
||||||
mcp = types.ModuleType("mcp")
|
mcp = types.ModuleType("mcp")
|
||||||
server = types.ModuleType("mcp.server")
|
server = types.ModuleType("mcp.server")
|
||||||
fastmcp = types.ModuleType("mcp.server.fastmcp")
|
fastmcp = types.ModuleType("mcp.server.fastmcp")
|
||||||
@@ -128,6 +157,7 @@ class McpAdapterTests(unittest.TestCase):
|
|||||||
[tool.__name__ for tool in instance.tools],
|
[tool.__name__ for tool in instance.tools],
|
||||||
[
|
[
|
||||||
"get_article",
|
"get_article",
|
||||||
|
"search_articles",
|
||||||
"calculate_roll_diameter",
|
"calculate_roll_diameter",
|
||||||
"analyze_transport_capacity",
|
"analyze_transport_capacity",
|
||||||
],
|
],
|
||||||
@@ -136,10 +166,16 @@ class McpAdapterTests(unittest.TestCase):
|
|||||||
def test_server_schema_exposes_weight_and_transport_chaining_contract(self):
|
def test_server_schema_exposes_weight_and_transport_chaining_contract(self):
|
||||||
instance = mcp_server.create_server()
|
instance = mcp_server.create_server()
|
||||||
tools = instance._tool_manager._tools
|
tools = instance._tool_manager._tools
|
||||||
|
search_tool = tools["search_articles"]
|
||||||
roll_schema = tools["calculate_roll_diameter"].parameters
|
roll_schema = tools["calculate_roll_diameter"].parameters
|
||||||
transport_tool = tools["analyze_transport_capacity"]
|
transport_tool = tools["analyze_transport_capacity"]
|
||||||
transport_schema = transport_tool.parameters
|
transport_schema = transport_tool.parameters
|
||||||
|
|
||||||
|
self.assertEqual(search_tool.parameters["required"], ["query"])
|
||||||
|
self.assertEqual(
|
||||||
|
search_tool.parameters["properties"]["query"]["type"], "string"
|
||||||
|
)
|
||||||
|
|
||||||
self.assertNotIn("include_roll_weight", roll_schema["properties"])
|
self.assertNotIn("include_roll_weight", roll_schema["properties"])
|
||||||
preset_schema = transport_schema["properties"]["transport_preset"]
|
preset_schema = transport_schema["properties"]["transport_preset"]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -195,6 +231,26 @@ class McpAdapterTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertIn(text, description)
|
self.assertIn(text, description)
|
||||||
|
|
||||||
|
search_description = search_tool.description
|
||||||
|
for text in (
|
||||||
|
"name, family, designation, or descriptive article text",
|
||||||
|
"Do not guess or invent an article number",
|
||||||
|
"deterministic candidates from the RollCalc article master data",
|
||||||
|
"total_matches is exactly 1",
|
||||||
|
"use that candidate's exact article_number",
|
||||||
|
"do NOT select the first or highest-ranked candidate",
|
||||||
|
"ask the user which article number is intended",
|
||||||
|
"do not calculate yet",
|
||||||
|
"no matching RollCalc article was found",
|
||||||
|
"more specific designation or article number",
|
||||||
|
"do not invent an article",
|
||||||
|
"production_site=Malaysia is descriptive metadata only",
|
||||||
|
"does not imply Bentofix, Bento 2, or any production machine",
|
||||||
|
"Ranking/order expresses relevance only",
|
||||||
|
"get_article -> calculate_roll_diameter -> optionally analyze_transport_capacity",
|
||||||
|
):
|
||||||
|
self.assertIn(text, search_description)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from roll_calculation import (
|
|||||||
CalculationRequest,
|
CalculationRequest,
|
||||||
CalculationState,
|
CalculationState,
|
||||||
_canonical_name,
|
_canonical_name,
|
||||||
|
article_production_site,
|
||||||
calculate_roll,
|
calculate_roll,
|
||||||
get_article,
|
get_article,
|
||||||
modify_calculation,
|
modify_calculation,
|
||||||
@@ -446,6 +447,128 @@ class RollCalculationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result["status"], "success")
|
self.assertEqual(result["status"], "success")
|
||||||
|
|
||||||
|
def test_search_exact_visible_name_keeps_duplicate_article_numbers(self):
|
||||||
|
result = ArticleRepository.load().search("Bfix NSP 4900, 5,00 x 40 m")
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "search_results")
|
||||||
|
self.assertEqual(result["total_matches"], 4)
|
||||||
|
self.assertEqual(result["candidates"][:2], [
|
||||||
|
{
|
||||||
|
"article_number": "180205",
|
||||||
|
"name": "Bfix NSP 4900, 5,00 x 40 m",
|
||||||
|
"width_m": 5.0,
|
||||||
|
"production_site": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"article_number": "8180205",
|
||||||
|
"name": "Bfix NSP 4900, 5,00 x 40 m",
|
||||||
|
"width_m": 5.0,
|
||||||
|
"production_site": "Malaysia",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_article_number_production_site_rule_only_identifies_malaysia(self):
|
||||||
|
self.assertEqual(article_production_site("8180205"), "Malaysia")
|
||||||
|
self.assertIsNone(article_production_site("180205"))
|
||||||
|
|
||||||
|
def test_search_candidates_expose_site_without_changing_order_or_resolve(self):
|
||||||
|
repository = ArticleRepository.load()
|
||||||
|
candidates = repository.search(
|
||||||
|
"Bfix NSP 4900, 5,00 x 40 m"
|
||||||
|
)["candidates"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[candidate["article_number"] for candidate in candidates],
|
||||||
|
["180205", "8180205", "182815", "206900"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[candidate["production_site"] for candidate in candidates],
|
||||||
|
[None, "Malaysia", None, None],
|
||||||
|
)
|
||||||
|
resolved = repository.resolve("8180205", None)
|
||||||
|
self.assertEqual(resolved["status"], "resolved")
|
||||||
|
self.assertNotIn("production_site", resolved["article"])
|
||||||
|
|
||||||
|
def test_search_bentofix_alias_matches_bfix_without_resolving_one_article(self):
|
||||||
|
repository = ArticleRepository.load()
|
||||||
|
|
||||||
|
full_name = repository.search("Bentofix NSP 4900")
|
||||||
|
abbreviated = repository.search("Bfix NSP 4900")
|
||||||
|
|
||||||
|
self.assertEqual(full_name["candidates"], abbreviated["candidates"])
|
||||||
|
self.assertGreater(full_name["total_matches"], 2)
|
||||||
|
self.assertIn("180205", [
|
||||||
|
candidate["article_number"] for candidate in full_name["candidates"]
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_search_partial_name_returns_related_products_without_resolving_one(self):
|
||||||
|
result = ArticleRepository.load().search("NSP 4900")
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "search_results")
|
||||||
|
self.assertGreater(result["total_matches"], 4)
|
||||||
|
self.assertIn("180205", [
|
||||||
|
candidate["article_number"] for candidate in result["candidates"]
|
||||||
|
])
|
||||||
|
self.assertIn("206900", [
|
||||||
|
candidate["article_number"] for candidate in result["candidates"]
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_search_qualifiers_and_dimensions_narrow_real_data_results(self):
|
||||||
|
repository = ArticleRepository.load()
|
||||||
|
base = repository.search("Bfix NSP 4900")
|
||||||
|
dimensions = repository.search("Bfix NSP 4900, 5,00 x 40 m")
|
||||||
|
qualified = repository.search("Bfix NSP 4900 GRI GCL3")
|
||||||
|
|
||||||
|
self.assertLess(dimensions["total_matches"], base["total_matches"])
|
||||||
|
self.assertEqual(
|
||||||
|
[candidate["article_number"] for candidate in qualified["candidates"]],
|
||||||
|
["206900"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_existing_family_aliases_are_equivalent(self):
|
||||||
|
repository = ArticleRepository.load()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
repository.search("Secutex R 1801")["candidates"],
|
||||||
|
repository.search("StEx R 1801")["candidates"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
repository.search("Secugrid 40/40 Q6")["candidates"],
|
||||||
|
repository.search("Sgrid 40/40 Q6")["candidates"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_is_stable_and_handles_empty_and_unknown_queries(self):
|
||||||
|
repository = ArticleRepository.load()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
repository.search("NSP 4900"), repository.search("NSP 4900")
|
||||||
|
)
|
||||||
|
self.assertEqual(repository.search(" "), {
|
||||||
|
"status": "search_not_found",
|
||||||
|
"query": "",
|
||||||
|
"total_matches": 0,
|
||||||
|
"candidates": [],
|
||||||
|
})
|
||||||
|
self.assertEqual(
|
||||||
|
repository.search("not a RollCalc product")["status"],
|
||||||
|
"search_not_found",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_search_reports_total_when_the_stable_result_list_is_capped(self):
|
||||||
|
result = ArticleRepository.load().search("Secugrid")
|
||||||
|
|
||||||
|
self.assertEqual(result["status"], "search_results")
|
||||||
|
self.assertGreater(result["total_matches"], len(result["candidates"]))
|
||||||
|
self.assertEqual(len(result["candidates"]), 20)
|
||||||
|
|
||||||
|
def test_search_does_not_change_existing_resolve_ambiguity(self):
|
||||||
|
repository = ArticleRepository.load()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
repository.resolve(None, "Bentofix NSP 4900")["status"],
|
||||||
|
"article_ambiguous",
|
||||||
|
)
|
||||||
|
|
||||||
def test_name_only_matching_uses_deterministic_bfix_alias(self):
|
def test_name_only_matching_uses_deterministic_bfix_alias(self):
|
||||||
result = self.calculate_fixture({
|
result = self.calculate_fixture({
|
||||||
"article_name_hint": "Bentofix Alias Product",
|
"article_name_hint": "Bentofix Alias Product",
|
||||||
|
|||||||
Reference in New Issue
Block a user