2 Commits
Author SHA1 Message Date
admin 0c18c86a47 Allow direct article resolution in machine max MCP 2026-09-26 13:52:08 +02:00
admin eca151a465 Reduce machine max MCP response size 2026-09-26 13:42:00 +02:00
5 changed files with 273 additions and 25 deletions
+7 -3
View File
@@ -150,14 +150,18 @@ Tool calling semantics:
width, and area weight. It requires no core diameter, does not infer one, and 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. cannot return a roll diameter or a core-inclusive total weight.
- `calculate_machine_max_product_length` composes configured machine limits - `calculate_machine_max_product_length` composes configured machine limits
with the shared calculation domain. Resolve an article first, then pass its with the shared calculation domain. With an exact article number, MCP resolves
properties unchanged. Its nominal maximum uses the nominal diameter limit; 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 its conservative/no-warning maximum uses RollCalc's existing maximum-diameter
thickness variation. Both also respect configured material-only maximum roll thickness variation. Both also respect configured material-only maximum roll
weight, and the domain returns the governing constraint(s), unrounded. weight, and the domain returns the governing constraint(s), unrounded.
Core and width incompatibility cannot be corrected by shortening and is Core and width incompatibility cannot be corrected by shortening and is
returned structurally. No manufacturing increment, core/packaging/gross 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 - `calculate_product_length` calculates required roll length from a target outer
diameter, core diameter, and material thickness. Its optional thickness diameter, core diameter, and material thickness. Its optional thickness
stddev returns minimum/average/maximum length ranges; optional width and area stddev returns minimum/average/maximum length ranges; optional width and area
+4 -2
View File
@@ -67,15 +67,17 @@ def create_server() -> Any:
def calculate_machine_max_product_length( def calculate_machine_max_product_length(
machine: str, machine: str,
core_diameter_mm: float, core_diameter_mm: float,
thickness_mm: float, article_number: str | None = None,
thickness_mm: float | None = None,
thickness_stddev_mm: float | None = None, thickness_stddev_mm: float | None = None,
product_width_m: float | None = None, product_width_m: float | None = None,
area_weight_g_m2: float | None = None, area_weight_g_m2: float | None = None,
) -> dict[str, Any]: ) -> 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( return calculate_machine_max_product_length_result(
machine=machine, machine=machine,
core_diameter_mm=core_diameter_mm, core_diameter_mm=core_diameter_mm,
article_number=article_number,
thickness_mm=thickness_mm, thickness_mm=thickness_mm,
thickness_stddev_mm=thickness_stddev_mm, thickness_stddev_mm=thickness_stddev_mm,
product_width_m=product_width_m, product_width_m=product_width_m,
+78 -3
View File
@@ -64,13 +64,49 @@ def calculate_machine_max_product_length_result(
*, *,
machine: str, machine: str,
core_diameter_mm: float, core_diameter_mm: float,
thickness_mm: float, article_number: str | None = None,
thickness_mm: float | None = None,
thickness_stddev_mm: float | None = None, thickness_stddev_mm: float | None = None,
product_width_m: float | None = None, product_width_m: float | None = None,
area_weight_g_m2: float | None = None, area_weight_g_m2: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Delegate machine-aware maximum product length to the shared domain.""" """Return the compact MCP view, resolving one exact article when supplied."""
return calculate_machine_max_product_length( 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, machine=machine,
core_diameter_mm=core_diameter_mm, core_diameter_mm=core_diameter_mm,
thickness_mm=thickness_mm, thickness_mm=thickness_mm,
@@ -78,6 +114,45 @@ def calculate_machine_max_product_length_result(
product_width_m=product_width_m, product_width_m=product_width_m,
area_weight_g_m2=area_weight_g_m2, 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],
*,
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.
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"]
projection = {
"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"],
}
if article is not None:
projection["article"] = {
"number": article["number"],
"name": article["name"],
}
return projection
def calculate_material_weight_result( def calculate_material_weight_result(
+32 -8
View File
@@ -5,6 +5,7 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
import app as rollcalc_app import app as rollcalc_app
from machine_constraints import calculate_machine_max_product_length
class CalculationApiTests(unittest.TestCase): class CalculationApiTests(unittest.TestCase):
@@ -140,26 +141,49 @@ class CalculationApiTests(unittest.TestCase):
self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"]) self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"])
def test_machine_maximum_length_endpoint_is_authenticated_and_structured(self): 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( response = self.client.post(
"/api/calculations/machine-max-product-length", "/api/calculations/machine-max-product-length",
json={ json=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,
},
headers=self.headers, headers=self.headers,
) )
result = response.get_json() result = response.get_json()
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(result["status"], "success") self.assertEqual(result["status"], "success")
self.assertEqual(result, calculate_machine_max_product_length(**payload))
self.assertIn("final_feasibility", result)
self.assertEqual( self.assertEqual(
result["production_maximums"]["nominal_governing_constraints"], result["production_maximums"]["nominal_governing_constraints"],
["roll_diameter_nominal"], ["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): def test_machine_maximum_length_endpoint_preserves_domain_outcomes(self):
incompatible = self.client.post( incompatible = self.client.post(
"/api/calculations/machine-max-product-length", "/api/calculations/machine-max-product-length",
+152 -9
View File
@@ -1,3 +1,4 @@
import copy
import inspect import inspect
import sys import sys
import types import types
@@ -21,6 +22,7 @@ from rollcalc_mcp_tools import (
calculate_roll_diameter_result, calculate_roll_diameter_result,
get_article_result, get_article_result,
get_machine_result, get_machine_result,
project_machine_max_product_length_result,
search_articles_result, search_articles_result,
) )
from machine_constraints import ( from machine_constraints import (
@@ -103,7 +105,7 @@ class McpAdapterTests(unittest.TestCase):
"feasible_with_warnings", "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 = { arguments = {
"machine": "K7", "machine": "K7",
"core_diameter_mm": 133.0, "core_diameter_mm": 133.0,
@@ -112,10 +114,149 @@ class McpAdapterTests(unittest.TestCase):
"product_width_m": 6.0, "product_width_m": 6.0,
"area_weight_g_m2": 495.395833, "area_weight_g_m2": 495.395833,
} }
self.assertEqual( domain_result = calculate_machine_max_product_length(**arguments)
calculate_machine_max_product_length_result(**arguments), domain_before_projection = copy.deepcopy(domain_result)
calculate_machine_max_product_length(**arguments),
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)
self.assertIn("final_feasibility", domain_result)
self.assertNotIn("final_feasibility", result)
self.assertEqual(
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_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",
"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): def test_material_weight_adapter_delegates_without_a_core_diameter(self):
arguments = { arguments = {
@@ -273,17 +414,19 @@ class McpAdapterTests(unittest.TestCase):
self.assertIn("Missing configured V1 roll inputs", feasibility_tool.description) self.assertIn("Missing configured V1 roll inputs", feasibility_tool.description)
self.assertEqual( self.assertEqual(
maximum_tool.parameters["required"], 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("product_width_m", maximum_tool.parameters["properties"])
self.assertIn("area_weight_g_m2", maximum_tool.parameters["properties"]) self.assertIn("area_weight_g_m2", maximum_tool.parameters["properties"])
for text in ( 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", "Do not supply or invent machine limits",
"rearrange roll or weight formulas",
"compare candidate limits yourself",
"nominal and conservative/no-warning production maxima", "nominal and conservative/no-warning production maxima",
"incompatibility rather than substituting another core or width",
): ):
self.assertIn(text, maximum_tool.description) self.assertIn(text, maximum_tool.description)
self.assertEqual( self.assertEqual(