diff --git a/README.md b/README.md index a291c3d..f9fe5bb 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,10 @@ Tool calling semantics: weight, and the domain returns the governing constraint(s), unrounded. Core and width incompatibility cannot be corrected by shortening and is returned structurally. No manufacturing increment, core/packaging/gross - weight, or conditional production rule is applied in V1.1. + weight, or conditional production rule is applied in V1.1. Its successful + MCP response is a compact projection: machine identity, configured maximum + diameter/material-weight limits, production maxima, material weights, and + warnings. Flask and other domain consumers retain the full diagnostic result. - `calculate_product_length` calculates required roll length from a target outer diameter, core diameter, and material thickness. Its optional thickness stddev returns minimum/average/maximum length ranges; optional width and area diff --git a/rollcalc_mcp_tools.py b/rollcalc_mcp_tools.py index 9dcf5dc..c3e4a34 100644 --- a/rollcalc_mcp_tools.py +++ b/rollcalc_mcp_tools.py @@ -69,8 +69,8 @@ def calculate_machine_max_product_length_result( product_width_m: float | None = None, area_weight_g_m2: float | None = None, ) -> dict[str, Any]: - """Delegate machine-aware maximum product length to the shared domain.""" - return calculate_machine_max_product_length( + """Return the compact MCP view of the machine-aware domain calculation.""" + result = calculate_machine_max_product_length( machine=machine, core_diameter_mm=core_diameter_mm, thickness_mm=thickness_mm, @@ -78,6 +78,35 @@ def calculate_machine_max_product_length_result( product_width_m=product_width_m, area_weight_g_m2=area_weight_g_m2, ) + return project_machine_max_product_length_result(result) + + +def project_machine_max_product_length_result(result: dict[str, Any]) -> dict[str, Any]: + """Project successful maximum-length results for MCP without diagnostics. + + The shared domain result deliberately contains detailed verification reports. + They remain available to Flask and other consumers, but duplicate machine + metadata and checks are unnecessary in the MCP response. + """ + if result.get("status") != "success": + return result + + machine = result["machine"] + constraints = machine["constraints"] + return { + "status": result["status"], + "machine": {"id": machine["id"], "name": machine["name"]}, + "weight_scope": result["weight_scope"], + "machine_limits": { + "maximum_roll_diameter_mm": constraints["roll_diameter_mm"]["maximum"] + if constraints["roll_diameter_mm"] is not None else None, + "maximum_material_roll_weight_kg": constraints["roll_weight_kg"]["maximum"] + if constraints["roll_weight_kg"] is not None else None, + }, + "production_maximums": result["production_maximums"], + "material_weights_kg": result["material_weights_kg"], + "warnings": result["warnings"], + } def calculate_material_weight_result( diff --git a/tests/test_calculation_api.py b/tests/test_calculation_api.py index 9e01d30..960f5fb 100644 --- a/tests/test_calculation_api.py +++ b/tests/test_calculation_api.py @@ -5,6 +5,7 @@ import unittest from unittest.mock import patch import app as rollcalc_app +from machine_constraints import calculate_machine_max_product_length class CalculationApiTests(unittest.TestCase): @@ -140,21 +141,24 @@ class CalculationApiTests(unittest.TestCase): self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"]) def test_machine_maximum_length_endpoint_is_authenticated_and_structured(self): + payload = { + "machine": "K7", + "core_diameter_mm": 133, + "thickness_mm": 4.311611, + "thickness_stddev_mm": 0.249999, + "product_width_m": 6, + "area_weight_g_m2": 495.395833, + } response = self.client.post( "/api/calculations/machine-max-product-length", - json={ - "machine": "K7", - "core_diameter_mm": 133, - "thickness_mm": 4.311611, - "thickness_stddev_mm": 0.249999, - "product_width_m": 6, - "area_weight_g_m2": 495.395833, - }, + json=payload, headers=self.headers, ) result = response.get_json() self.assertEqual(response.status_code, 200) self.assertEqual(result["status"], "success") + self.assertEqual(result, calculate_machine_max_product_length(**payload)) + self.assertIn("final_feasibility", result) self.assertEqual( result["production_maximums"]["nominal_governing_constraints"], ["roll_diameter_nominal"], diff --git a/tests/test_mcp_adapter.py b/tests/test_mcp_adapter.py index df2616d..974219c 100644 --- a/tests/test_mcp_adapter.py +++ b/tests/test_mcp_adapter.py @@ -1,3 +1,4 @@ +import copy import inspect import sys import types @@ -21,6 +22,7 @@ from rollcalc_mcp_tools import ( calculate_roll_diameter_result, get_article_result, get_machine_result, + project_machine_max_product_length_result, search_articles_result, ) from machine_constraints import ( @@ -103,7 +105,7 @@ class McpAdapterTests(unittest.TestCase): "feasible_with_warnings", ) - def test_machine_maximum_length_adapter_delegates_to_shared_domain(self): + def test_machine_maximum_length_adapter_projects_success_without_mutating_domain(self): arguments = { "machine": "K7", "core_diameter_mm": 133.0, @@ -112,10 +114,79 @@ class McpAdapterTests(unittest.TestCase): "product_width_m": 6.0, "area_weight_g_m2": 495.395833, } + domain_result = calculate_machine_max_product_length(**arguments) + domain_before_projection = copy.deepcopy(domain_result) + + result = project_machine_max_product_length_result(domain_result) + + self.assertEqual(domain_result, domain_before_projection) + self.assertIn("final_feasibility", domain_result) + self.assertNotIn("final_feasibility", result) self.assertEqual( - calculate_machine_max_product_length_result(**arguments), - calculate_machine_max_product_length(**arguments), + calculate_machine_max_product_length_result(**arguments), result ) + self.assertEqual(result["machine"], {"id": "k7", "name": "K7"}) + self.assertEqual(result["weight_scope"], "material_only") + self.assertEqual(result["machine_limits"], { + "maximum_roll_diameter_mm": 1100.0, + "maximum_material_roll_weight_kg": 700.0, + }) + 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, + }) + self.assertEqual(result["warnings"], {"nominal": ["roll_diameter_mm"]}) + + def test_machine_maximum_length_adapter_preserves_non_success_diagnostics(self): + base = { + "machine": "K7", + "core_diameter_mm": 133.0, + "thickness_mm": 4.311611, + "thickness_stddev_mm": 0.249999, + "product_width_m": 6.0, + "area_weight_g_m2": 495.395833, + } + for changes, status in ( + ({"core_diameter_mm": 194.0}, "incompatible"), + ({"product_width_m": 3.0}, "incompatible"), + ( + {"product_width_m": None, "area_weight_g_m2": None}, + "needs_clarification", + ), + ({"thickness_mm": -1.0}, "invalid_parameter"), + ({"machine": "not configured"}, "machine_not_found"), + ): + with self.subTest(changes=changes): + arguments = {**base, **changes} + domain_result = calculate_machine_max_product_length(**arguments) + adapter_result = calculate_machine_max_product_length_result(**arguments) + self.assertEqual(adapter_result, domain_result) + self.assertEqual(adapter_result["status"], status) + + missing = calculate_machine_max_product_length_result( + **{**base, "product_width_m": None, "area_weight_g_m2": None} + ) + self.assertEqual( + missing["missing_required_inputs"], + ["product_width_m", "area_weight_g_m2"], + ) + core = calculate_machine_max_product_length_result( + **{**base, "core_diameter_mm": 194.0} + ) + self.assertEqual( + core["failed_compatibility_constraints"], + ["allowed_core_diameters_mm"], + ) + width = calculate_machine_max_product_length_result( + **{**base, "product_width_m": 3.0} + ) + self.assertEqual(width["failed_compatibility_constraints"], ["product_width_m"]) def test_material_weight_adapter_delegates_without_a_core_diameter(self): arguments = {