Add deterministic article search to MCP
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user