Allow direct article resolution in machine max MCP

This commit is contained in:
2026-09-26 13:52:08 +02:00
parent eca151a465
commit 0c18c86a47
5 changed files with 154 additions and 13 deletions
+3 -2
View File
@@ -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.
+4 -2
View File
@@ -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,
+50 -4
View File
@@ -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(
+20
View File
@@ -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",
+77 -5
View File
@@ -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(