diff --git a/README.md b/README.md index f9fe5bb..1acab12 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,9 @@ Tool calling semantics: width, and area weight. It requires no core diameter, does not infer one, and cannot return a roll diameter or a core-inclusive total weight. - `calculate_machine_max_product_length` composes configured machine limits - with the shared calculation domain. Resolve an article first, then pass its - properties unchanged. Its nominal maximum uses the nominal diameter limit; + with the shared calculation domain. With an exact article number, MCP resolves + its properties internally; otherwise callers pass explicit properties. Its + nominal maximum uses the nominal diameter limit; its conservative/no-warning maximum uses RollCalc's existing maximum-diameter thickness variation. Both also respect configured material-only maximum roll weight, and the domain returns the governing constraint(s), unrounded. diff --git a/mcp_server.py b/mcp_server.py index 3897b95..f9a689b 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -67,15 +67,17 @@ def create_server() -> Any: def calculate_machine_max_product_length( machine: str, core_diameter_mm: float, - thickness_mm: float, + article_number: str | None = None, + thickness_mm: float | None = None, thickness_stddev_mm: float | None = None, product_width_m: float | None = None, area_weight_g_m2: float | None = None, ) -> dict[str, Any]: - """Calculate deterministic maximum product length for a configured production machine. Use this for maximum roll/product length, how much product fits on a machine roll, or maximum length without a diameter warning. Resolve the article first with search_articles/get_article when needed, then pass its thickness, thickness standard deviation, width, and area weight exactly. Do not supply or invent machine limits, rearrange roll or weight formulas, calculate a weight-limited length, or compare candidate limits yourself: this tool returns nominal and conservative/no-warning production maxima and their governing constraints. Report incompatibility rather than substituting another core or width, and ask for returned missing inputs rather than inventing them.""" + """Calculate deterministic maximum product length for a configured production machine. With an exact article_number, call this directly with machine and caller-supplied core_diameter_mm; do not call get_article merely to copy properties. Otherwise supply explicit material properties. Never mix article_number with material properties. For a product name, use search_articles; if candidates are multiple, ask the user to select an article number. Do not supply or invent machine limits or substitute a core or width; this returns nominal and conservative/no-warning production maxima and governing constraints.""" return calculate_machine_max_product_length_result( machine=machine, core_diameter_mm=core_diameter_mm, + article_number=article_number, thickness_mm=thickness_mm, thickness_stddev_mm=thickness_stddev_mm, product_width_m=product_width_m, diff --git a/rollcalc_mcp_tools.py b/rollcalc_mcp_tools.py index c3e4a34..c148f61 100644 --- a/rollcalc_mcp_tools.py +++ b/rollcalc_mcp_tools.py @@ -64,12 +64,48 @@ def calculate_machine_max_product_length_result( *, machine: str, core_diameter_mm: float, - thickness_mm: float, + article_number: str | None = None, + thickness_mm: float | None = None, thickness_stddev_mm: float | None = None, product_width_m: float | None = None, area_weight_g_m2: float | None = None, ) -> dict[str, Any]: - """Return the compact MCP view of the machine-aware domain calculation.""" + """Return the compact MCP view, resolving one exact article when supplied.""" + explicit_material = { + "thickness_mm": thickness_mm, + "thickness_stddev_mm": thickness_stddev_mm, + "product_width_m": product_width_m, + "area_weight_g_m2": area_weight_g_m2, + } + if article_number is not None: + mixed_fields = [ + field for field, value in explicit_material.items() if value is not None + ] + if mixed_fields: + return { + "status": "invalid_parameter", + "invalid": [{ + "field": "article_number", + "message": ( + "cannot be combined with explicit material properties: " + + ", ".join(mixed_fields) + ), + }], + } + article_result = get_article(article_number) + if article_result["status"] != "resolved": + return article_result + article = article_result["article"] + result = calculate_machine_max_product_length( + machine=machine, + core_diameter_mm=core_diameter_mm, + thickness_mm=article["thickness_mm"], + thickness_stddev_mm=article["thickness_stddev_mm"], + product_width_m=article["width_m"], + area_weight_g_m2=article["area_weight_g_m2"], + ) + return project_machine_max_product_length_result(result, article=article) + result = calculate_machine_max_product_length( machine=machine, core_diameter_mm=core_diameter_mm, @@ -81,7 +117,11 @@ def calculate_machine_max_product_length_result( return project_machine_max_product_length_result(result) -def project_machine_max_product_length_result(result: dict[str, Any]) -> dict[str, Any]: +def project_machine_max_product_length_result( + result: dict[str, Any], + *, + article: dict[str, Any] | None = None, +) -> dict[str, Any]: """Project successful maximum-length results for MCP without diagnostics. The shared domain result deliberately contains detailed verification reports. @@ -93,7 +133,7 @@ def project_machine_max_product_length_result(result: dict[str, Any]) -> dict[st machine = result["machine"] constraints = machine["constraints"] - return { + projection = { "status": result["status"], "machine": {"id": machine["id"], "name": machine["name"]}, "weight_scope": result["weight_scope"], @@ -107,6 +147,12 @@ def project_machine_max_product_length_result(result: dict[str, Any]) -> dict[st "material_weights_kg": result["material_weights_kg"], "warnings": result["warnings"], } + if article is not None: + projection["article"] = { + "number": article["number"], + "name": article["name"], + } + return projection def calculate_material_weight_result( diff --git a/tests/test_calculation_api.py b/tests/test_calculation_api.py index 960f5fb..bcb2952 100644 --- a/tests/test_calculation_api.py +++ b/tests/test_calculation_api.py @@ -164,6 +164,26 @@ class CalculationApiTests(unittest.TestCase): ["roll_diameter_nominal"], ) + def test_machine_maximum_length_endpoint_does_not_accept_mcp_article_convenience(self): + response = self.client.post( + "/api/calculations/machine-max-product-length", + json={ + "article_number": "218500", + "machine": "K7", + "core_diameter_mm": 133, + }, + headers=self.headers, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json(), { + "status": "invalid_parameter", + "invalid": [{ + "field": "request", + "message": "unknown request fields: article_number", + }], + }) + def test_machine_maximum_length_endpoint_preserves_domain_outcomes(self): incompatible = self.client.post( "/api/calculations/machine-max-product-length", diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index 974219c..81d7769 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -117,6 +117,10 @@ class McpAdapterTests(unittest.TestCase): domain_result = calculate_machine_max_product_length(**arguments) domain_before_projection = copy.deepcopy(domain_result) + self.assertNotIn( + "article_number", + inspect.signature(calculate_machine_max_product_length).parameters, + ) result = project_machine_max_product_length_result(domain_result) self.assertEqual(domain_result, domain_before_projection) @@ -143,6 +147,72 @@ class McpAdapterTests(unittest.TestCase): }) self.assertEqual(result["warnings"], {"nominal": ["roll_diameter_mm"]}) + def test_machine_maximum_length_adapter_resolves_exact_article_compactly(self): + result = calculate_machine_max_product_length_result( + article_number="218500", + machine="K7", + core_diameter_mm=133.0, + ) + + self.assertEqual(result["status"], "success") + self.assertEqual(result["article"], { + "number": "218500", + "name": "Stex AS 501, 6,00 x 75 m", + }) + self.assertNotIn("thickness_mm", result) + self.assertNotIn("final_feasibility", result) + self.assertEqual(result["production_maximums"], { + "nominal_length_m": 217.1900177447768, + "nominal_governing_constraints": ["roll_diameter_nominal"], + "conservative_length_m": 194.62073281485985, + "conservative_governing_constraints": ["roll_diameter_conservative"], + }) + self.assertEqual(result["material_weights_kg"], { + "at_nominal_production_maximum": 645.570178559751, + "at_conservative_production_maximum": 578.4858003113276, + }) + + def test_machine_maximum_length_adapter_article_lookup_is_exact_and_core_is_caller_supplied(self): + missing = calculate_machine_max_product_length_result( + article_number="does-not-exist", + machine="K7", + core_diameter_mm=133.0, + ) + incompatible_core = calculate_machine_max_product_length_result( + article_number="218500", + machine="K7", + core_diameter_mm=194.0, + ) + + self.assertEqual(missing, { + "status": "article_not_found", + "article_number": "does-not-exist", + }) + self.assertEqual(incompatible_core["status"], "incompatible") + self.assertEqual( + incompatible_core["failed_compatibility_constraints"], + ["allowed_core_diameters_mm"], + ) + + def test_machine_maximum_length_adapter_rejects_mixed_article_and_material_inputs(self): + result = calculate_machine_max_product_length_result( + article_number="218500", + machine="K7", + core_diameter_mm=133.0, + thickness_mm=4.311611, + ) + + self.assertEqual(result, { + "status": "invalid_parameter", + "invalid": [{ + "field": "article_number", + "message": ( + "cannot be combined with explicit material properties: " + "thickness_mm" + ), + }], + }) + def test_machine_maximum_length_adapter_preserves_non_success_diagnostics(self): base = { "machine": "K7", @@ -344,17 +414,19 @@ class McpAdapterTests(unittest.TestCase): self.assertIn("Missing configured V1 roll inputs", feasibility_tool.description) self.assertEqual( maximum_tool.parameters["required"], - ["machine", "core_diameter_mm", "thickness_mm"], + ["machine", "core_diameter_mm"], ) + self.assertIn("article_number", maximum_tool.parameters["properties"]) self.assertIn("product_width_m", maximum_tool.parameters["properties"]) self.assertIn("area_weight_g_m2", maximum_tool.parameters["properties"]) for text in ( - "Resolve the article first with search_articles/get_article", + "exact article_number", + "do not call get_article merely to copy properties", + "Never mix article_number with material properties", + "For a product name, use search_articles", + "if candidates are multiple, ask the user to select an article number", "Do not supply or invent machine limits", - "rearrange roll or weight formulas", - "compare candidate limits yourself", "nominal and conservative/no-warning production maxima", - "incompatibility rather than substituting another core or width", ): self.assertIn(text, maximum_tool.description) self.assertEqual(