feat: add conversational RollCalc assistant

This commit is contained in:
2026-08-29 22:37:56 +02:00
parent 3b44048250
commit fa3000b325
25 changed files with 6820 additions and 48 deletions
+231
View File
@@ -0,0 +1,231 @@
import base64
from datetime import datetime, timezone
import os
import tempfile
import unittest
from pdf_report import (
ReportValidationError,
report_from_calculation_result,
render_roll_report,
safe_report_filename,
validate_report_payload,
)
from roll_calculation import calculate_roll
try:
import app as rollcalc_app
except ModuleNotFoundError as error:
if error.name not in {"flask", "flask_httpauth", "werkzeug"}:
raise
rollcalc_app = None
def normal_payload(*, include_weight=True, include_warning=True):
payload = {
"report_type": "roll_diameter",
"article": {"number": "146900", "name": "Stex R 1801, 5.80 x 50 m"},
"inputs": [
{"label": "Core diameter", "value": "150.0 mm"},
{"label": "Core type", "value": "150mm PVC"},
{"label": "Product thickness", "value": "10.885 mm"},
{"label": "Thickness stddev", "value": "0.427 mm"},
{"label": "Product length", "value": "50.00 m"},
{"label": "Roll width", "value": "5.80 m"},
{"label": "Area weight", "value": "1767.2 g/m²"},
],
"diameters": {
"minimum": {"value": 612.3, "display": "612.3 mm"},
"average": {"value": 635.7, "display": "635.7 mm"},
"maximum": {"value": 658.1, "display": "658.1 mm"},
},
"roll_length": {"value": 50.0, "display": "50.00 m"},
"roll_weight": (
{"value": 512.5, "display": "512.5 kg"} if include_weight else None
),
"warnings": [],
"notes": [],
}
if include_warning:
payload["warnings"].append(
{
"severity": "warning",
"title": "Heavy Roll - Special Equipment Required",
"message": "Special equipment required. Core diameter too small.",
}
)
return payload
def normal_request(*, include_weight=True):
return {
"intent": "calculate_roll_diameter",
"article_number": "146900",
"roll_length_m": 50.0,
"width_m": 5.8 if include_weight else None,
"core_diameter_mm": 150.0,
"category": "bentofix",
"include_roll_weight": include_weight,
}
@unittest.skipUnless(rollcalc_app, "Flask dependencies are not installed")
class PdfEndpointTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.original_log_file = rollcalc_app.LOG_FILE
rollcalc_app.LOG_FILE = os.path.join(self.temp_dir.name, "access.json")
rollcalc_app.app.config.update(TESTING=True)
self.client = rollcalc_app.app.test_client()
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
self.auth_headers = {"Authorization": f"Basic {credentials}"}
def tearDown(self):
rollcalc_app.LOG_FILE = self.original_log_file
self.temp_dir.cleanup()
def post_report(self, payload):
return self.client.post(
"/api/reports/roll-calculation.pdf",
json=payload,
headers=self.auth_headers,
)
def test_normal_request_is_recalculated_into_one_page_pdf(self):
response = self.post_report(normal_request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, "application/pdf")
self.assertTrue(response.data.startswith(b"%PDF-1.4"))
self.assertEqual(response.data.count(b"/Type /Page "), 1)
self.assertIn(b"CALCULATION RESULTS", response.data)
self.assertNotIn(b"CALCULATED DIAMETERS", response.data)
for expected in (
b"813.1 mm",
b"845.9 mm",
b"877.4 mm",
b"50.00 m",
b"512.5 kg",
b"within standard limits",
):
self.assertIn(expected, response.data)
self.assertGreaterEqual(response.data.count(b"INTERNAL USE ONLY"), 3)
self.assertIn("roll-calculation_146900_", response.headers["Content-Disposition"])
def test_report_without_optional_weight_still_succeeds(self):
response = self.post_report(
normal_request(include_weight=False)
)
self.assertEqual(response.status_code, 200)
self.assertNotIn(b"CALCULATED ROLL WEIGHT", response.data)
self.assertIn(b"845.9 mm", response.data)
def test_incomplete_or_caller_supplied_results_are_rejected(self):
incomplete = normal_request()
del incomplete["roll_length_m"]
response = self.post_report(incomplete)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.mimetype, "application/json")
caller_result = normal_request()
caller_result["diameters"] = {"average": 1}
response = self.post_report(caller_result)
self.assertEqual(response.status_code, 400)
def test_endpoint_requires_authentication(self):
response = self.client.post(
"/api/reports/roll-calculation.pdf", json=normal_payload()
)
self.assertEqual(response.status_code, 401)
class PdfReportModelTests(unittest.TestCase):
def test_low_level_renderer_consumes_validated_layout_model(self):
report = validate_report_payload(normal_payload())
pdf = render_roll_report(
report,
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
build_info={"version": "v0.4", "commit": "fe267b8"},
)
self.assertIn(b"612.3 mm", pdf)
self.assertIn(b"635.7 mm", pdf)
self.assertIn(b"658.1 mm", pdf)
self.assertIn(b"50.00 m", pdf)
def test_non_numeric_diameter_is_rejected(self):
payload = normal_payload()
payload["diameters"]["average"]["value"] = "635.7"
with self.assertRaises(ReportValidationError):
validate_report_payload(payload)
def test_display_value_mismatch_is_rejected(self):
payload = normal_payload()
payload["diameters"]["average"]["display"] = "999.9 mm"
with self.assertRaises(ReportValidationError):
validate_report_payload(payload)
def test_effective_roll_length_is_required_and_must_match_display(self):
missing = normal_payload()
del missing["roll_length"]
with self.assertRaises(ReportValidationError):
validate_report_payload(missing)
mismatched = normal_payload()
mismatched["roll_length"]["display"] = "60.00 m"
with self.assertRaises(ReportValidationError):
validate_report_payload(mismatched)
def test_filename_is_filesystem_safe(self):
filename = safe_report_filename(
"Article 12/../../unsafe",
datetime(2026, 8, 28, tzinfo=timezone.utc),
)
self.assertEqual(
filename,
"roll-calculation_Article-12-..-..-unsafe_2026-08-28_000000.pdf",
)
self.assertNotIn(":", filename)
def test_pdf_adapter_consumes_authoritative_calculation_result(self):
result = calculate_roll(normal_request())
report = report_from_calculation_result(result)
pdf = render_roll_report(
report,
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
build_info={"version": "v0.4", "commit": "fe267b8"},
)
self.assertIn(b"845.9 mm", pdf)
self.assertIn(b"50.00 m", pdf)
self.assertIn(b"512.5 kg", pdf)
def test_multiple_warnings_are_wrapped_without_creating_a_second_page(self):
payload = normal_payload()
payload["warnings"] = [
{
"severity": "warning",
"title": f"Warning {index}",
"message": (
"This complete engineering warning remains in the report and "
"wraps when the available line width is reached."
),
}
for index in range(1, 7)
]
report = validate_report_payload(payload)
pdf = render_roll_report(
report,
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
build_info={"version": "v0.4", "commit": "fe267b8"},
)
self.assertEqual(pdf.count(b"/Type /Page "), 1)
for index in range(1, 7):
self.assertIn(f"Warning {index}".encode("ascii"), pdf)
if __name__ == "__main__":
unittest.main()