Add deterministic article search to MCP

This commit is contained in:
2026-09-25 15:13:02 +02:00
parent 6f224a1831
commit 1169df96a8
5 changed files with 284 additions and 2 deletions
+58 -2
View File
@@ -5,11 +5,12 @@ import unittest
from unittest.mock import patch
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 (
analyze_transport_capacity_result,
calculate_roll_diameter_result,
get_article_result,
search_articles_result,
)
from transport_calculation import analyze_transport, transport_presets
@@ -39,6 +40,34 @@ class McpAdapterTests(unittest.TestCase):
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):
for arguments in (
{
@@ -111,7 +140,7 @@ class McpAdapterTests(unittest.TestCase):
self.assertEqual(result["analysis"]["final_rolls"], 24)
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")
server = types.ModuleType("mcp.server")
fastmcp = types.ModuleType("mcp.server.fastmcp")
@@ -128,6 +157,7 @@ class McpAdapterTests(unittest.TestCase):
[tool.__name__ for tool in instance.tools],
[
"get_article",
"search_articles",
"calculate_roll_diameter",
"analyze_transport_capacity",
],
@@ -136,10 +166,16 @@ class McpAdapterTests(unittest.TestCase):
def test_server_schema_exposes_weight_and_transport_chaining_contract(self):
instance = mcp_server.create_server()
tools = instance._tool_manager._tools
search_tool = tools["search_articles"]
roll_schema = tools["calculate_roll_diameter"].parameters
transport_tool = tools["analyze_transport_capacity"]
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"])
preset_schema = transport_schema["properties"]["transport_preset"]
self.assertEqual(
@@ -195,6 +231,26 @@ class McpAdapterTests(unittest.TestCase):
):
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__":
unittest.main()
+123
View File
@@ -7,6 +7,7 @@ from roll_calculation import (
CalculationRequest,
CalculationState,
_canonical_name,
article_production_site,
calculate_roll,
get_article,
modify_calculation,
@@ -446,6 +447,128 @@ class RollCalculationTests(unittest.TestCase):
)
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):
result = self.calculate_fixture({
"article_name_hint": "Bentofix Alias Product",