Compare commits
13
Commits
v0.4
...
0c18c86a47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c18c86a47 | ||
|
|
eca151a465 | ||
|
|
90c28fb2f5 | ||
|
|
3e13d24405 | ||
|
|
e6a7d4da72 | ||
|
|
580e9caa5e | ||
|
|
1169df96a8 | ||
|
|
6f224a1831 | ||
|
|
78b00fedf8 | ||
|
|
bbaaaf31c5 | ||
|
|
defbf20202 | ||
|
|
fa3000b325 | ||
|
|
3b44048250 |
+85
-12
@@ -4,7 +4,7 @@ Project-specific technical and domain knowledge for RollCalcPython.
|
||||
|
||||
## Purpose
|
||||
|
||||
RollCalcPython is an internal roll calculator for Naue roll products. The backend is a minimal Flask application that provides authentication, routing, static file delivery, and access logs. Most domain behavior runs in the browser inside `templates/roll_calculator.html`.
|
||||
RollCalcPython is an internal roll calculator for Naue roll products. The backend provides authentication, routing, static file delivery, access logs, and the authoritative direct roll-diameter calculation service. Other modes and the load optimizer still contain client-side behavior in `templates/roll_calculator.html`.
|
||||
|
||||
The tool is not intended as an unchecked source of truth. Inputs and outputs must be checked for plausibility before operational use.
|
||||
|
||||
@@ -19,6 +19,13 @@ Backend:
|
||||
- `/static/<path:filename>` is intended to serve static files behind Basic Auth.
|
||||
- `/api/health` returns health/version information.
|
||||
- `/api/user` returns the authenticated user.
|
||||
- `/api/calculations/roll` executes a `CalculationRequest` through the deterministic core.
|
||||
- `/api/calculations/roll/modify` applies controlled changes to `CalculationState` and recalculates.
|
||||
- `/api/calculations/transport` executes the shared transport-capacity service.
|
||||
- `/api/reports/roll-calculation.pdf` recalculates a request and renders its authoritative result.
|
||||
- `/api/conversations` creates a process-local conversational session.
|
||||
- `/api/conversations/<id>/messages` runs constrained NLU followed by the existing deterministic calculation functions.
|
||||
- `/api/conversations/reports/<id>.pdf` recalculates a stored input-only request through the existing PDF path.
|
||||
- Access events are appended to `access_log.json`.
|
||||
- `build_info.json` is loaded at startup and exposed to all templates as `build_info`.
|
||||
|
||||
@@ -30,6 +37,48 @@ Frontend:
|
||||
- `window.APP_CONFIG` is currently defined inline for forklift rules.
|
||||
- A disclaimer modal blocks use until the user checks the confirmation checkbox.
|
||||
- The footer displays build metadata: version, branch, commit, and timestamp.
|
||||
- A valid direct diameter request/result is retained as `window.CURRENT_ROLL_REQUEST` / `window.CURRENT_ROLL_RESULT` until an input changes.
|
||||
|
||||
Domain service:
|
||||
|
||||
- `roll_calculation.py` owns `CalculationRequest`, `CalculationState`, `ArticleRepository`, `get_article()`, `calculate_roll()`, and `modify_calculation()`.
|
||||
- `transport_calculation.py` owns `analyze_transport()` and the canonical active transport presets. The template calls the protected transport API and retains only UI rendering and SVG drawing.
|
||||
- Direct diameter ranges, optional weight, and direct-flow forklift warnings originate there.
|
||||
- Request parsing rejects calculated diameter, weight, warning, and calculation fields.
|
||||
- Natural-language clients may describe or modify a calculation request, but engineering results are generated exclusively by the deterministic Roll Calculator core.
|
||||
|
||||
Conversational adapter:
|
||||
|
||||
- `ollama_nlu.py` calls local Ollama directly through `/api/chat`, using `qwen3.5:35B-A3B`, temperature `0`, `think=false`, non-streaming output, and a strict JSON schema by default. `ROLLCALC_OLLAMA_MODEL` overrides the model name.
|
||||
- The NLU schema allows only `new_calculation`, `modify_calculation`, and `unsupported`. Modifications contain one or more explicitly allowed request-field changes, validated and applied atomically.
|
||||
- The model receives no formulas, product dataset, engineering results, warnings, or PDF content. After success it receives only a minimal current-input summary for referential follow-ups.
|
||||
- `conversation_service.py` owns `CalculationState`, the last deterministic result, typed pending clarification context, and temporary input-only report references. Ambiguous core context retains the canonical family and matching `CorePreset` candidates; invalid replies do not replace the previous valid state.
|
||||
- An identifier-free incomplete `new_calculation` from the model is converted to a follow-up only when a successful current state exists and at least one explicit modification-like value is present. Null fields inherit current inputs; a different article or explicit new-calculation phrase starts fresh.
|
||||
- User-facing answers and clarification messages are deterministic templates. Numeric output is formatted only from `CalculationResult`.
|
||||
- The adapter does not apply the browser's visible 150 mm core default. Current master data has no usable core value, so absent core information requires clarification.
|
||||
- Ollama/schema/timeout failures do not mutate calculation state.
|
||||
- The store is in-memory, process-local, non-persistent, and intended only for the initial demo.
|
||||
|
||||
OpenWebUI adapter:
|
||||
|
||||
- `integrations/openwebui/rollcalc_pipe.py` is a presentation/integration Pipe,
|
||||
not another NLU or calculation service. It calls only the existing
|
||||
conversation endpoints.
|
||||
- One process-local mapping from `(OpenWebUI user ID, chat ID)` to a RollCalc
|
||||
`conversation_id` preserves follow-up state without reconstructing it from the
|
||||
OpenWebUI transcript. Per-chat asynchronous locks preserve message order.
|
||||
- The Pipe renders only the deterministic API `message`. It validates the
|
||||
report path and creates Markdown links from `ROLLCALC_PUBLIC_BASE_URL`; it
|
||||
never exposes raw interpretation, result, state, or diagnostic objects.
|
||||
- `ROLLCALC_API_BASE_URL` is the server/container route, whereas
|
||||
`ROLLCALC_PUBLIC_BASE_URL` is the browser route. They are deliberately
|
||||
separate because container-local hostnames must not leak into PDF links.
|
||||
- Server-side API calls use configured Basic Auth. Credentials are never placed
|
||||
in PDF URLs, so users authenticate separately to the existing RollCalc PDF
|
||||
endpoint in their browser.
|
||||
- The Pipe's chat mapping is lost on OpenWebUI Function reload/restart. It is
|
||||
not shared by multiple OpenWebUI processes. Deployment and demo details live
|
||||
in `integrations/openwebui/README.md`.
|
||||
|
||||
## Build Info
|
||||
|
||||
@@ -79,7 +128,7 @@ Assumptions:
|
||||
- No air gaps.
|
||||
- Roll geometry is derived mathematically, not physically measured in the app.
|
||||
|
||||
Nominal diameter formula used by the Direct Calculation tab:
|
||||
Nominal diameter formula used by `roll_calculation.calculate_roll()` and returned to the Direct Calculation tab:
|
||||
|
||||
```text
|
||||
D = sqrt(d^2 + (4 * L * 1000 * t) / pi)
|
||||
@@ -117,7 +166,7 @@ Variables:
|
||||
|
||||
## Roll Weight
|
||||
|
||||
Roll weight is calculated client-side when width, area weight, and length are available:
|
||||
For the authoritative direct roll-diameter flow, roll weight is calculated server-side when requested and width, area weight, and length are available:
|
||||
|
||||
```text
|
||||
weight_kg = area_weight_g_m2 * length_m * width_m / 1000
|
||||
@@ -125,6 +174,30 @@ weight_kg = area_weight_g_m2 * length_m * width_m / 1000
|
||||
|
||||
Material thickness and area weight values are loaded from article data and are generated automatically. They are not guaranteed to be complete or reviewed.
|
||||
|
||||
## PDF Reports
|
||||
|
||||
The browser and PDF endpoints both submit a `CalculationRequest`. The PDF endpoint recalculates through `calculate_roll()` and passes its `CalculationResult` through `report_from_calculation_result()`; it never accepts client-supplied result fields. Minimum, average, maximum, effective roll length, optional weight, warnings, notes, effective inputs, and provenance therefore come from the same core.
|
||||
|
||||
`POST /api/reports/roll-calculation.pdf` is protected by the existing HTTP Basic Auth. Incomplete, conflicting, or invalid calculation requests return HTTP 400 rather than a document. Generated filenames include date and time without colon characters.
|
||||
|
||||
`pdf_report.py` uses no third-party package. It creates one A4 page with a compact header, result cards, two-column input area, warnings/notes, and footer. Repeated diagonal `INTERNAL USE ONLY` text is drawn into the actual page content stream with reduced opacity; it is a marking/deterrence mechanism and is not tamper-proof or DRM.
|
||||
|
||||
Direct-flow warnings are returned by the domain core and rendered by both consumers. `checkForklift()` remains only for the existing non-headless length/target UI modes. Build metadata and generation time are supplied server-side.
|
||||
|
||||
## Request Resolution and Clarification
|
||||
|
||||
- Article numbers are strings and exact matches take precedence.
|
||||
- Duplicate records for one exact article number require clarification; file order never decides the result.
|
||||
- A supplied name hint is validated conservatively; a clear mismatch produces `article_conflict`.
|
||||
- Article-name normalization in `roll_calculation.py` maps only the product-family tokens `Stex`/`Secutex`, `Bfix`/`Bentofix`, and `Sgrid`/`Secugrid` to their abbreviated canonical forms before deterministic matching. The mappings are token-bounded and do not alter `article-data.json`.
|
||||
- Unknown numbers/names produce `article_not_found`; duplicate exact names require clarification.
|
||||
- The service may extract one unambiguous width from standardized article-name dimensions and marks its source as `article_master_data`.
|
||||
- Required absent values produce `status = needs_clarification` with `missing` fields.
|
||||
- Canonical core presets live in `core_presets.py` and are rendered into the web UI as well as consumed by the headless resolver. The ordered list remains 133 mm steel, 150 mm PVC, 168 mm PVC, and 194 mm steel.
|
||||
- Complete preset labels and finite explicit German/English aliases resolve canonically. Generic `steel`/`Stahlkern` and `PVC`/plastic descriptions remain ambiguous because both families contain two presets; unknown descriptions never select a diameter.
|
||||
- `CalculationState` stores structured request fields. Mutations change only validated fields and always recalculate.
|
||||
- Unknown state fields, including caller-supplied calculation results, are rejected rather than ignored.
|
||||
|
||||
## Article Data
|
||||
|
||||
Active data source:
|
||||
@@ -156,7 +229,7 @@ Known domain caveat from existing project notes:
|
||||
|
||||
## Forklift and Heavy-Roll Rules
|
||||
|
||||
The active template currently evaluates forklift warnings through `checkForklift()` and inline `window.APP_CONFIG`.
|
||||
The direct roll-diameter service evaluates the following active behavior. Other UI modes still evaluate the equivalent inline `window.APP_CONFIG` rules through `checkForklift()`.
|
||||
|
||||
Current inline rule behavior:
|
||||
|
||||
@@ -175,14 +248,12 @@ That file includes localized messages and additional equipment requirements. Bef
|
||||
|
||||
## Load Optimizer
|
||||
|
||||
The Load Optimizer is implemented inline in `templates/roll_calculator.html`.
|
||||
`transport_calculation.py` is authoritative for transport presets and capacity
|
||||
analysis through `analyze_transport()`. The active template calls the protected
|
||||
transport endpoint and only collects inputs, renders returned values, and draws
|
||||
the SVG front view.
|
||||
|
||||
Classes:
|
||||
|
||||
- `LoadOptimizer`
|
||||
- `LoadOptimizerUI`
|
||||
|
||||
It uses calculated roll data from the Direct Calculation tab:
|
||||
It uses displayed roll data from the Direct Calculation tab:
|
||||
|
||||
- Roll diameter.
|
||||
- Core diameter.
|
||||
@@ -235,5 +306,7 @@ Login/access logging remains part of RollCalc, but log viewing and article-data
|
||||
- Flask's default static route may conflict with the intended authenticated `/static/<path:filename>` behavior; verify effective routing before relying on protected static files.
|
||||
- Most active logic is concentrated in one large HTML template.
|
||||
- Multiple similar static JavaScript files exist; not all are necessarily active.
|
||||
- No automated test suite is visible.
|
||||
- The deterministic service, state mutation, template integration, PDF adapter/renderer, and Flask endpoints have standard-library `unittest` coverage under `tests/`.
|
||||
- Some docs and filenames in `docs/` may reflect earlier package/archive versions.
|
||||
- Conversation sessions and report references are lost on restart and are not shared across WSGI workers.
|
||||
- Ollama availability/model installation is external runtime state; normal tests use mocks and do not require it.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RollCalcPython
|
||||
|
||||
Flask-based internal roll diameter calculator for Naue roll products. The app is a small authenticated Flask shell around a mostly client-side calculator UI.
|
||||
Flask-based internal roll diameter calculator for Naue roll products. Direct roll-diameter calculations are provided by a deterministic Python domain service and consumed by the authenticated browser UI and PDF path.
|
||||
|
||||
This README is intended for developers maintaining the project, not for end users.
|
||||
|
||||
@@ -11,6 +11,8 @@ This README is intended for developers maintaining the project, not for end user
|
||||
- Flask-HTTPAuth 4.8.0
|
||||
- Vanilla HTML, CSS, and JavaScript
|
||||
- JSON files for product data and forklift/load rules
|
||||
- Dependency-free server-side PDF report rendering
|
||||
- Direct standard-library HTTP integration with a local Ollama service
|
||||
|
||||
Pinned Python dependencies are defined in `requirements.txt`.
|
||||
|
||||
@@ -49,6 +51,17 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
|
||||
```text
|
||||
.
|
||||
├── app.py
|
||||
├── conversation_service.py
|
||||
├── core_presets.py
|
||||
├── ollama_nlu.py
|
||||
├── pdf_report.py
|
||||
├── roll_calculation.py
|
||||
├── transport_calculation.py
|
||||
├── rollcalc_chat.py
|
||||
├── integrations/
|
||||
│ └── openwebui/
|
||||
│ ├── README.md
|
||||
│ └── rollcalc_pipe.py
|
||||
├── requirements.txt
|
||||
├── README.md
|
||||
├── build_info.json
|
||||
@@ -59,6 +72,8 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
|
||||
├── service-worker.js
|
||||
├── templates/
|
||||
│ └── roll_calculator.html
|
||||
├── tests/
|
||||
│ └── test_pdf_report.py
|
||||
├── static/
|
||||
│ ├── article-data.json
|
||||
│ ├── config.json
|
||||
@@ -89,9 +104,93 @@ Implemented routes:
|
||||
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
|
||||
| `/api/health` | `GET` | Basic Auth | Returns app health and version. |
|
||||
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
|
||||
| `/api/calculations/roll` | `POST` | Basic Auth | Resolves, validates, and executes a direct roll-diameter request. |
|
||||
| `/api/calculations/roll/modify` | `POST` | Basic Auth | Applies explicit changes to structured calculation state and recalculates. |
|
||||
| `/api/calculations/transport` | `POST` | Basic Auth | Executes the shared transport-capacity analysis. |
|
||||
| `/api/calculations/production-feasibility` | `POST` | Basic Auth | Checks configured production-machine constraints. |
|
||||
| `/api/calculations/machine-max-product-length` | `POST` | Basic Auth | Calculates deterministic machine-aware maximum product lengths. |
|
||||
| `/api/reports/roll-calculation.pdf` | `POST` | Basic Auth | Recalculates a request authoritatively and returns a one-page PDF. |
|
||||
| `/api/conversations` | `POST` | Basic Auth | Creates an in-memory conversation session. |
|
||||
| `/api/conversations/<id>/messages` | `POST` | Basic Auth | Interprets one utterance and executes the deterministic workflow. |
|
||||
| `/api/conversations/reports/<id>.pdf` | `GET` | Basic Auth | Recalculates a stored input-only request and returns its PDF. |
|
||||
|
||||
Authentication is implemented with `Flask-HTTPAuth`. The current code checks `BETA_USERS` with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application.
|
||||
|
||||
## MCP PoC
|
||||
|
||||
`mcp_server.py` provides a local stdio-only Model Context Protocol server. It is
|
||||
a thin adapter over the same domain services used by Flask: article lookup,
|
||||
direct roll calculation, product-length calculation, material-weight calculation,
|
||||
transport analysis, and configured production-machine feasibility.
|
||||
The available PoC tools are `get_article`, `search_articles`,
|
||||
`calculate_material_weight`, `calculate_product_length`,
|
||||
`calculate_roll_diameter`, `analyze_transport_capacity`, `get_machine`,
|
||||
`check_production_feasibility`, and `calculate_machine_max_product_length`.
|
||||
|
||||
Install the pinned dependencies, including `mcp==1.26.0`, in the existing
|
||||
environment, then start it with:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pip install -r requirements.txt
|
||||
.venv/bin/python mcp_server.py
|
||||
```
|
||||
|
||||
The PoC has no MCP resources, HTTP/SSE transport, remote authentication, or
|
||||
dedicated target-length calculation tool or extrapolation support.
|
||||
|
||||
Tool calling semantics:
|
||||
|
||||
- `get_article` accepts textual `article_number` values (including leading
|
||||
zeroes) and an optional `article_name_hint`.
|
||||
- `search_articles` deterministically returns master-data candidates for a
|
||||
product name or designation. Its ordering is retrieval relevance, not a
|
||||
product recommendation: when multiple candidates remain, callers must ask
|
||||
the user to select an article number rather than choosing one.
|
||||
- `calculate_material_weight` calculates material-only weight from roll length,
|
||||
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. 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.
|
||||
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. 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
|
||||
weight return material-weight ranges. It does not infer missing article or
|
||||
material inputs.
|
||||
- `calculate_roll_diameter` uses `roll_length_m` for the material length on one
|
||||
roll. Diameter and thickness inputs are in mm; `width_m` is in m;
|
||||
`area_weight_g_m2` is in g/m². It requires a known positive core diameter;
|
||||
use `calculate_material_weight` when the core is unknown and only material
|
||||
weight is needed. On success, it returns `transport_roll_inputs`, the
|
||||
browser-equivalent transport bundle.
|
||||
- `analyze_transport_capacity` uses roll/core diameters in mm; roll width and
|
||||
transport dimensions in m; and roll/payload weights in kg.
|
||||
`product_length_m` is the material length represented by one roll, used only
|
||||
for reported square metres. For a chained calculation, pass every field from
|
||||
`transport_roll_inputs` unchanged. The supplied `roll_weight_kg` is passed
|
||||
through unchanged. Callers may use a canonical `transport_preset` or provide
|
||||
custom transport dimensions and `max_weight_kg`.
|
||||
- `get_machine` resolves a configured machine by stable ID, display name, or
|
||||
configured alias. Locations are descriptive metadata only.
|
||||
- `check_production_feasibility` evaluates all configured independent machine
|
||||
limits deterministically. Its V1 weight is material-only: it never estimates
|
||||
core, packaging, or gross weight. Callers pass `average_diameter_mm` and
|
||||
`maximum_diameter_mm` from `calculate_roll_diameter`; a nominal fit with a
|
||||
maximum-diameter exceedance is `feasible_with_warnings`, while a nominal
|
||||
exceedance is `not_feasible`. Missing configured roll inputs produce
|
||||
`needs_clarification`; line speed is retained in configuration but outside
|
||||
the initial roll-feasibility scope.
|
||||
|
||||
Generate a password hash or user entry with:
|
||||
|
||||
```bash
|
||||
@@ -109,6 +208,147 @@ Do not commit real passwords or print them in logs.
|
||||
|
||||
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back.
|
||||
|
||||
## Local Ollama NLU Adapter
|
||||
|
||||
The local LLM is an NLU adapter only. Engineering calculations remain deterministic.
|
||||
|
||||
`ollama_nlu.py` calls Ollama's `/api/chat` endpoint directly with the Python standard library. The default configuration is:
|
||||
|
||||
```text
|
||||
model: qwen3.5:35B-A3B
|
||||
base URL: http://127.0.0.1:11434
|
||||
temperature: 0.0
|
||||
think: false
|
||||
timeout: 45 seconds
|
||||
stream: false
|
||||
format: strict JSON schema
|
||||
```
|
||||
|
||||
Configuration can be overridden without editing code:
|
||||
|
||||
```bash
|
||||
export ROLLCALC_OLLAMA_URL=http://127.0.0.1:11434
|
||||
export ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B
|
||||
export ROLLCALC_OLLAMA_TIMEOUT_SECONDS=45
|
||||
export ROLLCALC_OLLAMA_TEMPERATURE=0
|
||||
```
|
||||
|
||||
The model receives the user utterance and, after a successful calculation, a minimal summary of the current input state: resolved article reference, roll length, width, core, and weight-request flag. It does not receive formulas, the article/master dataset, calculated diameters or weights, warnings, or PDF data.
|
||||
|
||||
The accepted model output has exactly one of these shapes:
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": null,
|
||||
"core_type": null,
|
||||
"core_diameter_mm": null,
|
||||
"include_roll_weight": false
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {
|
||||
"roll_length_m": 80.0,
|
||||
"include_roll_weight": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "unsupported"
|
||||
}
|
||||
```
|
||||
|
||||
Every optional field must be present in `new_calculation` and use `null` when the user did not supply it. A modification supports one or more explicitly enumerated input fields (`roll_length_m`, `width_m`, `core_type`, `core_diameter_mm`, and `include_roll_weight`, plus the existing article identifiers). Unknown fields, invalid types, markdown-wrapped JSON, and calculated diameter/weight/warning fields are rejected by application validation even if Ollama returns them. All requested changes are validated and applied atomically; an unresolved clarification retains the complete structured patch until it can be applied.
|
||||
|
||||
### Conversation State and Reports
|
||||
|
||||
`conversation_service.py` owns the current `CalculationState`, last deterministic result, and typed pending clarification. It never reconstructs technical state from chat history. When a successful state exists, an identifier-free `new_calculation` interpretation containing only explicit modification-like values is narrowly converted into a state modification; omitted fields are inherited, while an explicit different article or explicit “new calculation” phrase starts fresh. Successful calculations create an unguessable temporary report ID that stores only the validated calculation request. PDF download recalculates that request through `calculate_roll()` and uses the existing renderer.
|
||||
|
||||
The store is an MVP process-local memory store:
|
||||
|
||||
- sessions and report references disappear on restart;
|
||||
- state is not shared between multiple WSGI processes;
|
||||
- no expiration, persistence, or cross-process locking is implemented;
|
||||
- successful report references remain until process restart.
|
||||
|
||||
### OpenWebUI Browser Adapter
|
||||
|
||||
`integrations/openwebui/rollcalc_pipe.py` is a small OpenWebUI Pipe Function.
|
||||
It registers **RollCalc Assistant** as a selectable model and forwards each user
|
||||
message to the existing conversation API. It neither calls a second model nor
|
||||
contains article, formula, warning, clarification, state-mutation, or PDF
|
||||
logic.
|
||||
|
||||
The Pipe keeps a process-local mapping from `(OpenWebUI user ID, chat ID)` to a
|
||||
RollCalc `conversation_id`. Only the API's deterministic `message` is displayed.
|
||||
An API report path is converted into a Markdown download link using a separately
|
||||
configured browser-reachable RollCalc URL.
|
||||
|
||||
Required configuration:
|
||||
|
||||
```bash
|
||||
export ROLLCALC_API_BASE_URL=http://rollcalc.internal:5000
|
||||
export ROLLCALC_PUBLIC_BASE_URL=https://rollcalc.example.internal
|
||||
export ROLLCALC_USERNAME=service-user
|
||||
export ROLLCALC_PASSWORD='set-outside-the-repository'
|
||||
```
|
||||
|
||||
The API credentials are server-side only and are never embedded in report URLs.
|
||||
The browser must authenticate separately against RollCalc's existing Basic-Auth
|
||||
challenge. Installation, network-topology checks, Valve configuration, demo
|
||||
steps, and MVP limitations are documented in
|
||||
`integrations/openwebui/README.md`.
|
||||
|
||||
### Terminal Demo
|
||||
|
||||
Ensure Ollama is running and the configured model is installed, then run:
|
||||
|
||||
```bash
|
||||
python -m rollcalc_chat
|
||||
```
|
||||
|
||||
For validation output containing raw model JSON, validated interpretation, deterministic outcome, and latency:
|
||||
|
||||
```bash
|
||||
python -m rollcalc_chat --debug
|
||||
```
|
||||
|
||||
Optional real-model/manual article-name matrix (run the prompts in one
|
||||
`python -m rollcalc_chat --debug` session):
|
||||
|
||||
| Prompt | Expected deterministic outcome |
|
||||
| --- | --- |
|
||||
| `Berechne Artikel 146900 mit 50 m und einem 150-mm-PVC-Kern.` | Exact article number `146900` resolves. |
|
||||
| `Berechne Stex R 1801, 5,80 x 50 m mit 50 m und einem 150-mm-PVC-Kern.` | The normalized full name resolves to `146900`. |
|
||||
| `Berechne stex r 1801; 5.80 × 50 m mit 50 m und einem 150-mm-PVC-Kern.` | Case and safe punctuation normalization still resolve to `146900`. |
|
||||
| `Berechne Stex R 1801 mit 50 m und einem 150-mm-PVC-Kern.` | Clarification lists `146900` (5,80 x 50 m) and `146910` (6,00 x 50 m). |
|
||||
| `146900` | Resolves the pending article clarification without another Ollama call. |
|
||||
| `Berechne Artikel 180205, Stex R 1801, mit 40 m und einem 150-mm-PVC-Kern.` | Deterministic `article_conflict`; neither identifier is silently preferred. |
|
||||
| `Berechne Nicht vorhandenes Produkt XYZ mit 40 m und einem 150-mm-PVC-Kern.` | Concise deterministic article-not-found response. |
|
||||
|
||||
The model may extract an article number or name, but all resolution and every
|
||||
displayed candidate come from `static/article-data.json`.
|
||||
|
||||
The initial scope supports new direct roll-diameter calculations, multi-field modifications, and clarification replies. Product-length mode, target-diameter mode, extrapolation, load optimization, general questions, and arbitrary tool execution return `unsupported`.
|
||||
|
||||
The conversational adapter does not reuse the browser's visible 150 mm core default. Current article data contains no usable core mapping, so a request without an explicit known core or core diameter asks for clarification. Width remains optional for diameter-only calculations and becomes required only when roll weight is explicitly requested.
|
||||
|
||||
Normal tests mock Ollama. To opt into the real-model smoke test explicitly:
|
||||
|
||||
```bash
|
||||
ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B \
|
||||
ROLLCALC_RUN_OLLAMA_SMOKE=1 python -m unittest discover \
|
||||
-s tests -p 'test_ollama_real_smoke.py' -v
|
||||
```
|
||||
|
||||
## Frontend Entry Point
|
||||
|
||||
The active UI is `templates/roll_calculator.html`.
|
||||
@@ -121,8 +361,10 @@ The template contains:
|
||||
- Global state:
|
||||
- `window.ARTICLE_DATA`
|
||||
- `window.APP_CONFIG`
|
||||
- `window.CURRENT_ROLL_REQUEST`
|
||||
- `window.CURRENT_ROLL_RESULT`
|
||||
- Product/article data loading from `/static/article-data.json`.
|
||||
- Direct roll calculations.
|
||||
- Direct roll-diameter request/response rendering.
|
||||
- Extrapolation calculations.
|
||||
- Forklift/weight warnings.
|
||||
- Load optimizer UI and calculations.
|
||||
@@ -172,7 +414,15 @@ The direct calculation tab supports multiple modes via mode buttons:
|
||||
- Product length from core diameter, material thickness, and roll diameter.
|
||||
- Product length for a target diameter.
|
||||
|
||||
The nominal roll diameter formula used in the template is:
|
||||
The direct roll-diameter mode posts raw decision inputs to `/api/calculations/roll`.
|
||||
The Product Length mode posts to `/api/calculations/product-length`. The
|
||||
authoritative implementations are `calculate_roll()` and
|
||||
`calculate_product_length()` in `roll_calculation.py`; the template only renders
|
||||
their results.
|
||||
|
||||
The four core preset buttons are rendered from the canonical ordered `CORE_PRESETS` tuple in `core_presets.py`. Headless and conversational core resolution uses the same tuple and its finite German/English aliases. Generic steel and PVC family names remain ambiguous because the current UI contains two presets for each family.
|
||||
|
||||
The nominal formula implemented there is:
|
||||
|
||||
```text
|
||||
D = sqrt(d^2 + (4 * L * 1000 * t) / pi)
|
||||
@@ -187,12 +437,31 @@ Where:
|
||||
|
||||
If a tolerance/stddev value is present, the UI also displays a `-2σ` and `+2σ` diameter range.
|
||||
|
||||
Roll weight is calculated when roll width, area weight, and length are present:
|
||||
Optional roll weight is calculated by the same service when requested and when roll width, area weight, and length are available:
|
||||
|
||||
```text
|
||||
weight_kg = area_weight_g_m2 * length_m * width_m / 1000
|
||||
```
|
||||
|
||||
In the browser, weight remains optional: the direct-diameter request asks for a weight only when both width and area weight are present. A future machine client can request it explicitly with `include_roll_weight`; missing required weight inputs then produce structured clarification.
|
||||
|
||||
### PDF Calculation Report
|
||||
|
||||
After a valid direct roll-diameter calculation, the result card enables **Download PDF**. The browser posts the original `CalculationRequest`, not calculated result values. The server calls the same `calculate_roll()` service, adapts that authoritative `CalculationResult`, and renders it. Caller-supplied diameters, weights, and warnings are rejected as request fields.
|
||||
|
||||
The A4 report contains:
|
||||
|
||||
- article/reference data when present;
|
||||
- all material calculation inputs, including category/site when selected;
|
||||
- minimum, average, and maximum diameter plus the effective calculated roll length, using the UI's values and rounding;
|
||||
- optional calculated roll weight;
|
||||
- applicable forklift warnings or notes;
|
||||
- generation time and build metadata.
|
||||
|
||||
PDF rendering is implemented in `pdf_report.py` using native PDF page and content primitives. It requires no browser installation and no additional Python package. The repeated diagonal `INTERNAL USE ONLY` marks are painted into the page content stream. They are a visible internal-use deterrent, not DRM or a cryptographically tamper-proof control.
|
||||
|
||||
The filename includes the article reference and local generation time as `roll-calculation_<article>_YYYY-MM-DD_HHMMSS.pdf`. The button is invalidated when a calculation input changes and is currently enabled for the normal direct roll-diameter mode.
|
||||
|
||||
### Extrapolation
|
||||
|
||||
The extrapolation tab estimates a new roll diameter from a known roll diameter/length pair and a new target length:
|
||||
@@ -211,7 +480,7 @@ Where:
|
||||
|
||||
### Forklift Check
|
||||
|
||||
`checkForklift()` in `templates/roll_calculator.html` evaluates heavy-roll warnings using `window.APP_CONFIG.forklift_rules`.
|
||||
For the authoritative direct roll-diameter flow, `roll_calculation.py` evaluates the current heavy-roll thresholds and returns structured warnings/notes for the UI and PDF. The legacy browser `checkForklift()` remains in use only by the other client-side direct modes.
|
||||
|
||||
The current inline config targets the `bentofix` category and includes:
|
||||
|
||||
@@ -223,12 +492,12 @@ There is also a richer `static/config.json` with localized messages and more det
|
||||
|
||||
### Load Optimizer
|
||||
|
||||
The Load Optimizer is implemented by inline classes in `templates/roll_calculator.html`:
|
||||
`transport_calculation.py` is the authoritative capacity calculation. It owns
|
||||
the active transport presets and `analyze_transport()`. The browser collects
|
||||
inputs, calls `POST /api/calculations/transport`, renders the returned analysis,
|
||||
and draws the SVG front view; it does not calculate capacity itself.
|
||||
|
||||
- `LoadOptimizer`
|
||||
- `LoadOptimizerUI`
|
||||
|
||||
It uses calculated roll data from the Direct Calculation tab and estimates loading capacity for transport presets or custom dimensions.
|
||||
It uses the displayed roll data from the Direct Calculation tab and estimates loading capacity for transport presets or custom dimensions. In particular, the direct tab writes the roll weight with one decimal place before the optimizer consumes it; this behavior is intentionally preserved.
|
||||
|
||||
Preset transport units include:
|
||||
|
||||
@@ -272,6 +541,17 @@ Primary product/article dataset used by the active calculator UI.
|
||||
|
||||
JSON configuration for forklift/heavy-roll rules. The active template also contains an inline `window.APP_CONFIG`, so developers should verify which config source is authoritative before changing rule behavior.
|
||||
|
||||
### `config/machines.yaml`
|
||||
|
||||
Runtime configuration for production-machine constraints, loaded with
|
||||
`yaml.safe_load` and strict schema validation by `machine_constraints.py`.
|
||||
It has `schema_version: 1`, stable IDs, names, aliases, locations, and explicit
|
||||
unit-bearing constraint keys. `null` line-speed constraints mean unknown/not
|
||||
configured; they do not mean zero or unlimited capability. The CSV in
|
||||
`data/source/LineLimitations_RollCalc.csv` remains the source/reference
|
||||
document. The browser currently has no production-machine control; the shared
|
||||
Flask API and MCP tools expose the capability for later UI work.
|
||||
|
||||
### `access_log.json`
|
||||
|
||||
JSON audit log written by `app.py`.
|
||||
@@ -311,6 +591,6 @@ Utility script that updates relative frontend fetch/register paths to Flask-styl
|
||||
- `access_log.json` grows without rotation or retention limits.
|
||||
- The custom `/static/<path:filename>` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection.
|
||||
- RollCalc no longer includes an admin UI/API; article data administration belongs in a separate application.
|
||||
- There is no visible automated test suite.
|
||||
- Calculation, mutation, UI-integration, PDF-model, and endpoint tests use Python's standard-library `unittest`; endpoint tests require the normal dependencies from `requirements.txt` to be installed.
|
||||
- The main template is large and mixes layout, styling, data loading, calculations, and UI behavior.
|
||||
- The disclaimer confirmation is client-side only and is not persisted or audited server-side.
|
||||
|
||||
@@ -3,13 +3,45 @@ Naue Roll Calculator - Beta
|
||||
Flask app with HTTP Basic Authentication
|
||||
"""
|
||||
|
||||
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
jsonify,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
send_from_directory,
|
||||
)
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from werkzeug.security import check_password_hash
|
||||
from functools import wraps
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from core_presets import CORE_PRESETS
|
||||
from machine_constraints import (
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
)
|
||||
from conversation_service import (
|
||||
ConversationNotFoundError,
|
||||
ConversationReportNotFoundError,
|
||||
ConversationService,
|
||||
)
|
||||
from ollama_nlu import OllamaNLUClient
|
||||
from pdf_report import (
|
||||
ReportValidationError,
|
||||
report_from_calculation_result,
|
||||
render_roll_report,
|
||||
safe_report_filename,
|
||||
)
|
||||
from roll_calculation import (
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
modify_calculation,
|
||||
)
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
app = Flask(__name__)
|
||||
auth = HTTPBasicAuth()
|
||||
@@ -74,12 +106,18 @@ def load_build_info():
|
||||
return build_info
|
||||
|
||||
BUILD_INFO = load_build_info()
|
||||
CONVERSATION_SERVICE = ConversationService(
|
||||
OllamaNLUClient(),
|
||||
build_info=BUILD_INFO,
|
||||
)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_build_info():
|
||||
"""Make build metadata available in all templates."""
|
||||
return {"build_info": BUILD_INFO}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION
|
||||
# ============================================================================
|
||||
@@ -96,6 +134,7 @@ def verify_password(username, password):
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
def log_access(username, endpoint, method, status=200):
|
||||
"""Log all access attempts for audit trail"""
|
||||
log_entry = {
|
||||
@@ -125,7 +164,12 @@ def log_access(username, endpoint, method, status=200):
|
||||
def index():
|
||||
"""Main calculator page - requires authentication"""
|
||||
log_access(auth.current_user(), "/", "GET")
|
||||
return render_template("roll_calculator.html")
|
||||
return render_template(
|
||||
"roll_calculator.html",
|
||||
core_presets=CORE_PRESETS,
|
||||
transport_presets=transport_presets(),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/static/<path:filename>", methods=["GET"])
|
||||
@auth.login_required
|
||||
@@ -134,12 +178,14 @@ def serve_static(filename):
|
||||
log_access(auth.current_user(), f"/static/{filename}", "GET")
|
||||
return send_from_directory("static", filename)
|
||||
|
||||
|
||||
@app.route("/api/health", methods=["GET"])
|
||||
@auth.login_required
|
||||
def health_check():
|
||||
"""Simple health check endpoint"""
|
||||
return jsonify({"status": "ok", "version": "14.1"}), 200
|
||||
|
||||
|
||||
@app.route("/api/user", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_user():
|
||||
@@ -151,6 +197,255 @@ def get_user():
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/roll", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_roll_calculation():
|
||||
"""Resolve and execute a deterministic direct roll-diameter request."""
|
||||
result = calculate_roll(request.get_json(silent=True), build_info=BUILD_INFO)
|
||||
log_access(auth.current_user(), "/api/calculations/roll", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/product-length", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_product_length_calculation():
|
||||
"""Calculate deterministic product length for a target roll diameter."""
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict):
|
||||
result = {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{"field": "request", "message": "JSON object required"}],
|
||||
}
|
||||
else:
|
||||
allowed = {
|
||||
"target_roll_diameter_mm",
|
||||
"core_diameter_mm",
|
||||
"thickness_mm",
|
||||
"thickness_stddev_mm",
|
||||
"width_m",
|
||||
"area_weight_g_m2",
|
||||
}
|
||||
unknown = sorted(set(payload) - allowed)
|
||||
if unknown:
|
||||
result = {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{
|
||||
"field": "request",
|
||||
"message": "unknown request fields: " + ", ".join(unknown),
|
||||
}],
|
||||
}
|
||||
else:
|
||||
result = calculate_product_length(**payload)
|
||||
log_access(auth.current_user(), "/api/calculations/product-length", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/roll/modify", methods=["POST"])
|
||||
@auth.login_required
|
||||
def modify_roll_calculation():
|
||||
"""Apply explicit changes to structured calculation state and recalculate."""
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict):
|
||||
return jsonify({"status": "invalid_parameter", "invalid": [
|
||||
{"field": "request", "message": "JSON object required"}
|
||||
]}), 400
|
||||
result = modify_calculation(
|
||||
payload.get("state"),
|
||||
payload.get("changes"),
|
||||
build_info=BUILD_INFO,
|
||||
)
|
||||
log_access(auth.current_user(), "/api/calculations/roll/modify", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/transport", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_transport_calculation():
|
||||
"""Execute the shared transport-capacity calculation."""
|
||||
result = analyze_transport(request.get_json(silent=True))
|
||||
log_access(auth.current_user(), "/api/calculations/transport", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/production-feasibility", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_production_feasibility_calculation():
|
||||
"""Check configured production-machine constraints through the shared domain."""
|
||||
result = check_production_feasibility(request.get_json(silent=True))
|
||||
log_access(
|
||||
auth.current_user(),
|
||||
"/api/calculations/production-feasibility",
|
||||
"POST",
|
||||
)
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/machine-max-product-length", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_machine_max_product_length_calculation():
|
||||
"""Calculate machine-limited product length through the shared domain."""
|
||||
payload = request.get_json(silent=True)
|
||||
allowed = {
|
||||
"machine",
|
||||
"core_diameter_mm",
|
||||
"thickness_mm",
|
||||
"thickness_stddev_mm",
|
||||
"product_width_m",
|
||||
"area_weight_g_m2",
|
||||
}
|
||||
if not isinstance(payload, dict):
|
||||
result = {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{"field": "request", "message": "JSON object required"}],
|
||||
}
|
||||
else:
|
||||
unknown = sorted(set(payload) - allowed)
|
||||
if unknown:
|
||||
result = {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{
|
||||
"field": "request",
|
||||
"message": "unknown request fields: " + ", ".join(unknown),
|
||||
}],
|
||||
}
|
||||
else:
|
||||
result = calculate_machine_max_product_length(
|
||||
machine=payload.get("machine"),
|
||||
core_diameter_mm=payload.get("core_diameter_mm"),
|
||||
thickness_mm=payload.get("thickness_mm"),
|
||||
thickness_stddev_mm=payload.get("thickness_stddev_mm"),
|
||||
product_width_m=payload.get("product_width_m"),
|
||||
area_weight_g_m2=payload.get("area_weight_g_m2"),
|
||||
)
|
||||
log_access(
|
||||
auth.current_user(), "/api/calculations/machine-max-product-length", "POST"
|
||||
)
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
def _create_pdf(calculation_request):
|
||||
result = calculate_roll(calculation_request, build_info=BUILD_INFO)
|
||||
report = report_from_calculation_result(result)
|
||||
generated_at = datetime.now().astimezone()
|
||||
pdf = render_roll_report(
|
||||
report,
|
||||
generated_at=generated_at,
|
||||
build_info=BUILD_INFO,
|
||||
)
|
||||
article_number = report["article"]["number"] if report["article"] else None
|
||||
filename = safe_report_filename(article_number, generated_at)
|
||||
return pdf, filename
|
||||
|
||||
|
||||
@app.route("/api/reports/roll-calculation.pdf", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_roll_calculation_report():
|
||||
"""Recalculate a request authoritatively and render its one-page PDF."""
|
||||
try:
|
||||
pdf, filename = _create_pdf(request.get_json(silent=True))
|
||||
except ReportValidationError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
log_access(
|
||||
auth.current_user(),
|
||||
"/api/reports/roll-calculation.pdf",
|
||||
"POST",
|
||||
)
|
||||
return send_file(
|
||||
BytesIO(pdf),
|
||||
mimetype="application/pdf",
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
max_age=0,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/conversations", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_conversation():
|
||||
"""Create one process-local conversational calculation session."""
|
||||
conversation_id = CONVERSATION_SERVICE.create_conversation()
|
||||
log_access(auth.current_user(), "/api/conversations", "POST", 201)
|
||||
return jsonify({
|
||||
"status": "created",
|
||||
"conversation_id": conversation_id,
|
||||
}), 201
|
||||
|
||||
|
||||
@app.route(
|
||||
"/api/conversations/<conversation_id>/messages",
|
||||
methods=["POST"],
|
||||
)
|
||||
@auth.login_required
|
||||
def create_conversation_message(conversation_id):
|
||||
"""Interpret one utterance and run the deterministic calculation workflow."""
|
||||
payload = request.get_json(silent=True)
|
||||
if not isinstance(payload, dict) or set(payload) != {"message"}:
|
||||
return jsonify({
|
||||
"status": "invalid_parameter",
|
||||
"message": "JSON object with exactly one message field required",
|
||||
}), 400
|
||||
if not isinstance(payload["message"], str) or not payload["message"].strip():
|
||||
return jsonify({
|
||||
"status": "invalid_parameter",
|
||||
"message": "message must be non-empty text",
|
||||
}), 400
|
||||
try:
|
||||
result = CONVERSATION_SERVICE.handle_message(
|
||||
conversation_id,
|
||||
payload["message"],
|
||||
)
|
||||
except ConversationNotFoundError:
|
||||
return jsonify({
|
||||
"status": "conversation_not_found",
|
||||
"message": "Conversation does not exist or has expired",
|
||||
}), 404
|
||||
|
||||
status_code = 200
|
||||
if result.get("status") == "nlu_error":
|
||||
status_code = 503 if result.get("error") in {
|
||||
"OllamaTimeoutError",
|
||||
"OllamaUnavailableError",
|
||||
} else 502
|
||||
log_access(
|
||||
auth.current_user(),
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
"POST",
|
||||
status_code,
|
||||
)
|
||||
return jsonify(result), status_code
|
||||
|
||||
|
||||
@app.route(
|
||||
"/api/conversations/reports/<report_id>.pdf",
|
||||
methods=["GET"],
|
||||
)
|
||||
@auth.login_required
|
||||
def get_conversation_report(report_id):
|
||||
"""Recalculate a stored input-only report request and return its PDF."""
|
||||
try:
|
||||
calculation_request = CONVERSATION_SERVICE.report_request(report_id)
|
||||
pdf, filename = _create_pdf(calculation_request)
|
||||
except ConversationReportNotFoundError:
|
||||
return jsonify({"error": "Conversation report not found"}), 404
|
||||
except ReportValidationError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
log_access(
|
||||
auth.current_user(),
|
||||
f"/api/conversations/reports/{report_id}.pdf",
|
||||
"GET",
|
||||
)
|
||||
return send_file(
|
||||
BytesIO(pdf),
|
||||
mimetype="application/pdf",
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
max_age=0,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ERROR HANDLERS
|
||||
# ============================================================================
|
||||
@@ -160,16 +455,19 @@ def unauthorized(e):
|
||||
"""Handle 401 Unauthorized - browser will prompt for credentials"""
|
||||
return jsonify({"error": "Unauthorized - please provide valid credentials"}), 401
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
"""Handle 404 Not Found"""
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
"""Handle 500 Internal Server Error"""
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STARTUP
|
||||
# ============================================================================
|
||||
@@ -177,7 +475,7 @@ def internal_error(e):
|
||||
if __name__ == "__main__":
|
||||
os.makedirs("templates", exist_ok=True)
|
||||
os.makedirs("static", exist_ok=True)
|
||||
|
||||
|
||||
print("""
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ Naue Roll Calculator - V14 + QoL Update ║
|
||||
@@ -188,7 +486,7 @@ if __name__ == "__main__":
|
||||
║ Starten auf: http://localhost:5000 ║
|
||||
╚═══════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
|
||||
app.run(
|
||||
host="0.0.0.0",
|
||||
port=5000,
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "v0.4-dev",
|
||||
"branch": "develop/v0.4",
|
||||
"commit": "7a27fb8",
|
||||
"timestamp": "2026-07-07T16:46:32+02:00"
|
||||
"version": "v0.4",
|
||||
"branch": "main",
|
||||
"commit": "fe267b8",
|
||||
"timestamp": "2026-07-27T11:08:47+02:00"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
schema_version: 1
|
||||
|
||||
machines:
|
||||
- id: bento_1
|
||||
name: Bento 1
|
||||
aliases: [Bento1, B1]
|
||||
location: Espelkamp (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 2500}
|
||||
roll_diameter_mm: {minimum: 300, maximum: 876}
|
||||
allowed_core_diameters_mm: [150, 168, 170, 200, 235]
|
||||
product_width_m: {minimum: 4.7, maximum: 5.0}
|
||||
line_speed_m_per_min: {minimum: 0.9, maximum: 6.0}
|
||||
- id: bento_2
|
||||
name: Bento 2
|
||||
aliases: [Bento2, B2]
|
||||
location: Selangor (Malaysia)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 2500}
|
||||
roll_diameter_mm: {minimum: 250, maximum: 1000}
|
||||
allowed_core_diameters_mm: [150, 168, 170, 200, 235]
|
||||
product_width_m: {minimum: 4.7, maximum: 5.0}
|
||||
line_speed_m_per_min: {minimum: 0.9, maximum: 6.0}
|
||||
- id: k1
|
||||
name: K1
|
||||
aliases: [K 1]
|
||||
location: Selangor (Malaysia)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 800}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 1100}
|
||||
allowed_core_diameters_mm: [130, 133, 150, 168, 170]
|
||||
product_width_m: {minimum: 4.0, maximum: 6.1}
|
||||
line_speed_m_per_min: {minimum: 1.0, maximum: 20.0}
|
||||
- id: k_3_4
|
||||
name: K3/4
|
||||
aliases: [K 34, K 3/4]
|
||||
location: Espelkamp (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 800}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 1100}
|
||||
allowed_core_diameters_mm: [130, 133, 150, 168, 170]
|
||||
product_width_m: {minimum: 4.0, maximum: 6.1}
|
||||
line_speed_m_per_min: {minimum: 1.0, maximum: 15.0}
|
||||
- id: k5
|
||||
name: K5
|
||||
aliases: [K 5]
|
||||
location: Adorf (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 800}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 1200}
|
||||
allowed_core_diameters_mm: [130, 133, 150, 168, 170]
|
||||
product_width_m: {minimum: 4.0, maximum: 6.1}
|
||||
line_speed_m_per_min: {minimum: 1.0, maximum: 20.0}
|
||||
- id: k6
|
||||
name: K6
|
||||
aliases: [K 6]
|
||||
location: Selangor (Malaysia)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 800}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 1100}
|
||||
allowed_core_diameters_mm: [130, 133, 150, 168, 170]
|
||||
product_width_m: {minimum: 4.0, maximum: 6.1}
|
||||
line_speed_m_per_min: {minimum: 1.0, maximum: 20.0}
|
||||
- id: k7
|
||||
name: K7
|
||||
aliases: [K 7]
|
||||
location: Espelkamp (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 700}
|
||||
roll_diameter_mm: {minimum: 300, maximum: 1100}
|
||||
allowed_core_diameters_mm: [130, 133, 150, 168, 170]
|
||||
product_width_m: {minimum: 4.0, maximum: 6.1}
|
||||
line_speed_m_per_min: {minimum: 0.6, maximum: 40.0}
|
||||
- id: e1
|
||||
name: E1
|
||||
aliases: [E 1]
|
||||
location: Espelkamp (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 2500}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 1000}
|
||||
allowed_core_diameters_mm: [150, 168, 170]
|
||||
product_width_m: {minimum: 7.5, maximum: 7.5}
|
||||
line_speed_m_per_min: {minimum: 0.6, maximum: 10.0}
|
||||
- id: e4
|
||||
name: E4
|
||||
aliases: [E 4]
|
||||
location: Espelkamp (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 2000}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 900}
|
||||
allowed_core_diameters_mm: [150, 168, 170]
|
||||
product_width_m: {minimum: 5.1, maximum: 5.1}
|
||||
line_speed_m_per_min: {minimum: 0.5, maximum: 10.0}
|
||||
- id: gitter_1
|
||||
name: Gitter 1
|
||||
aliases: [G1, G 1]
|
||||
location: Adorf (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 400}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 600}
|
||||
allowed_core_diameters_mm: [140]
|
||||
product_width_m: {minimum: 4.0, maximum: 4.75}
|
||||
line_speed_m_per_min: null
|
||||
- id: gitter_2
|
||||
name: Gitter 2
|
||||
aliases: [G2, G 2]
|
||||
location: Adorf (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 400}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 600}
|
||||
allowed_core_diameters_mm: [140]
|
||||
product_width_m: {minimum: 4.0, maximum: 4.75}
|
||||
line_speed_m_per_min: null
|
||||
- id: gitter_3
|
||||
name: Gitter 3
|
||||
aliases: [G3, G 3]
|
||||
location: Adorf (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 400}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 600}
|
||||
allowed_core_diameters_mm: [140]
|
||||
product_width_m: {minimum: 4.0, maximum: 4.75}
|
||||
line_speed_m_per_min: null
|
||||
- id: gitter_4
|
||||
name: Gitter 4
|
||||
aliases: [G4, G 4]
|
||||
location: Adorf (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 400}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 600}
|
||||
allowed_core_diameters_mm: [140]
|
||||
product_width_m: {minimum: 4.0, maximum: 4.75}
|
||||
line_speed_m_per_min: null
|
||||
- id: secudrain_1_2
|
||||
name: Secudrain 1/2
|
||||
aliases: [Sec 1/2, Secudrain 12, Sec 12]
|
||||
location: Adorf (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 400}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 800}
|
||||
allowed_core_diameters_mm: [120]
|
||||
product_width_m: {minimum: 2.0, maximum: 4.0}
|
||||
line_speed_m_per_min: null
|
||||
- id: secudrain_3
|
||||
name: Secudrain 3
|
||||
aliases: [Sec 3]
|
||||
location: Adorf (Germany)
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 400}
|
||||
roll_diameter_mm: {minimum: 200, maximum: 800}
|
||||
allowed_core_diameters_mm: [120]
|
||||
product_width_m: {minimum: 1.8, maximum: 2.1}
|
||||
line_speed_m_per_min: null
|
||||
@@ -0,0 +1,812 @@
|
||||
"""Application-owned conversational workflow around deterministic RollCalc APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
import math
|
||||
import secrets
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from core_presets import CorePreset, core_family, matching_core_presets
|
||||
from ollama_nlu import (
|
||||
ClarificationInterpretation,
|
||||
NLUError,
|
||||
NLUInterpretation,
|
||||
NLUResponse,
|
||||
OllamaNLUClient,
|
||||
parse_clarification_reply,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
|
||||
|
||||
class ConversationNotFoundError(KeyError):
|
||||
"""Raised when an in-memory conversation ID is unknown."""
|
||||
|
||||
|
||||
class ConversationReportNotFoundError(KeyError):
|
||||
"""Raised when an in-memory report ID is unknown."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArticleCandidate:
|
||||
article_number: str
|
||||
name: str
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"article_number": self.article_number,
|
||||
"name": self.name,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingClarification:
|
||||
field: str
|
||||
reason: str
|
||||
core_family: str | None = None
|
||||
candidates: tuple[CorePreset, ...] = ()
|
||||
article_candidates: tuple[ArticleCandidate, ...] = ()
|
||||
requested_changes: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def allowed_values(self) -> tuple[float, ...]:
|
||||
return tuple(preset.diameter_mm for preset in self.candidates)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = {
|
||||
"field": self.field,
|
||||
"reason": self.reason,
|
||||
"core_family": self.core_family,
|
||||
"allowed_values": list(self.allowed_values),
|
||||
"candidates": [
|
||||
{
|
||||
"key": preset.key,
|
||||
"label": preset.label,
|
||||
"diameter_mm": preset.diameter_mm,
|
||||
"material": preset.material,
|
||||
}
|
||||
for preset in self.candidates
|
||||
],
|
||||
}
|
||||
if self.requested_changes:
|
||||
result["requested_changes"] = deepcopy(self.requested_changes)
|
||||
if self.reason == "ambiguous_article":
|
||||
result.pop("core_family")
|
||||
result.pop("allowed_values")
|
||||
result["candidates"] = [
|
||||
candidate.to_dict() for candidate in self.article_candidates
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationRecord:
|
||||
calculation_state: dict[str, Any] | None = None
|
||||
last_result: dict[str, Any] | None = None
|
||||
pending_clarification: PendingClarification | None = None
|
||||
|
||||
@property
|
||||
def pending_fields(self) -> tuple[str, ...]:
|
||||
if self.pending_clarification is None:
|
||||
return ()
|
||||
return (self.pending_clarification.field,)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredReport:
|
||||
request: dict[str, Any]
|
||||
conversation_id: str
|
||||
|
||||
|
||||
class InMemoryConversationStore:
|
||||
"""Process-local MVP storage; all values are copied across its boundary."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._conversations: dict[str, ConversationRecord] = {}
|
||||
self._reports: dict[str, StoredReport] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def create(self) -> str:
|
||||
conversation_id = secrets.token_urlsafe(18)
|
||||
with self._lock:
|
||||
self._conversations[conversation_id] = ConversationRecord()
|
||||
return conversation_id
|
||||
|
||||
def get(self, conversation_id: str) -> ConversationRecord:
|
||||
with self._lock:
|
||||
record = self._conversations.get(conversation_id)
|
||||
if record is None:
|
||||
raise ConversationNotFoundError(conversation_id)
|
||||
return deepcopy(record)
|
||||
|
||||
def save(self, conversation_id: str, record: ConversationRecord) -> None:
|
||||
with self._lock:
|
||||
if conversation_id not in self._conversations:
|
||||
raise ConversationNotFoundError(conversation_id)
|
||||
self._conversations[conversation_id] = deepcopy(record)
|
||||
|
||||
def add_report(self, conversation_id: str, request: dict[str, Any]) -> str:
|
||||
report_id = secrets.token_urlsafe(18)
|
||||
with self._lock:
|
||||
if conversation_id not in self._conversations:
|
||||
raise ConversationNotFoundError(conversation_id)
|
||||
self._reports[report_id] = StoredReport(
|
||||
request=deepcopy(request),
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
return report_id
|
||||
|
||||
def get_report(self, report_id: str) -> StoredReport:
|
||||
with self._lock:
|
||||
report = self._reports.get(report_id)
|
||||
if report is None:
|
||||
raise ConversationReportNotFoundError(report_id)
|
||||
return deepcopy(report)
|
||||
|
||||
|
||||
def _calculation_request(interpretation: NLUInterpretation) -> dict[str, Any]:
|
||||
payload = interpretation.to_dict()
|
||||
payload["intent"] = "calculate_roll_diameter"
|
||||
return payload
|
||||
|
||||
|
||||
def _has_valid_calculation(record: ConversationRecord) -> bool:
|
||||
return (
|
||||
record.calculation_state is not None
|
||||
and isinstance(record.last_result, dict)
|
||||
and record.last_result.get("status") == "success"
|
||||
)
|
||||
|
||||
|
||||
def _current_nlu_context(record: ConversationRecord) -> dict[str, Any] | None:
|
||||
if not _has_valid_calculation(record):
|
||||
return None
|
||||
request = record.calculation_state.get("request", {})
|
||||
if not isinstance(request, dict):
|
||||
return None
|
||||
fields = (
|
||||
"article_number",
|
||||
"article_name_hint",
|
||||
"roll_length_m",
|
||||
"width_m",
|
||||
"core_type",
|
||||
"core_diameter_mm",
|
||||
"include_roll_weight",
|
||||
)
|
||||
resolved_article = record.last_result.get("resolved_article", {})
|
||||
resolved_name = (
|
||||
resolved_article.get("name")
|
||||
if isinstance(resolved_article, dict)
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"has_successful_calculation": True,
|
||||
"resolved_article_name": resolved_name,
|
||||
**{field: request.get(field) for field in fields},
|
||||
}
|
||||
|
||||
|
||||
def _new_calculation_followup_changes(
|
||||
interpretation: NLUInterpretation,
|
||||
record: ConversationRecord,
|
||||
message: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if interpretation.intent != "new_calculation" or not _has_valid_calculation(record):
|
||||
return None
|
||||
if interpretation.article_number is not None:
|
||||
return None
|
||||
if interpretation.article_name_hint is not None:
|
||||
return None
|
||||
normalized_message = " ".join(message.casefold().split())
|
||||
if any(
|
||||
marker in normalized_message
|
||||
for marker in (
|
||||
"neue berechnung",
|
||||
"neue rollenberechnung",
|
||||
"new calculation",
|
||||
"start over",
|
||||
)
|
||||
):
|
||||
return None
|
||||
|
||||
changes = {}
|
||||
for field in (
|
||||
"roll_length_m",
|
||||
"width_m",
|
||||
"core_type",
|
||||
"core_diameter_mm",
|
||||
):
|
||||
value = getattr(interpretation, field)
|
||||
if value is not None:
|
||||
changes[field] = value
|
||||
if interpretation.include_roll_weight:
|
||||
changes["include_roll_weight"] = True
|
||||
return changes or None
|
||||
|
||||
|
||||
def _pending_clarification(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
requested_changes: dict[str, Any] | None = None,
|
||||
) -> PendingClarification | None:
|
||||
missing = result.get("missing", [])
|
||||
ambiguous = result.get("ambiguous", [])
|
||||
for field in (
|
||||
"width_m",
|
||||
"core_diameter_mm",
|
||||
"roll_length_m",
|
||||
"thickness_mm",
|
||||
"area_weight_g_m2",
|
||||
):
|
||||
if field in missing:
|
||||
return PendingClarification(
|
||||
field,
|
||||
"missing_parameter",
|
||||
requested_changes=deepcopy(requested_changes or {}),
|
||||
)
|
||||
if "core_type" in ambiguous:
|
||||
request = result.get("request", {})
|
||||
core_type = request.get("core_type") if isinstance(request, dict) else None
|
||||
if isinstance(core_type, str):
|
||||
candidates = matching_core_presets(core_type)
|
||||
family = core_family(core_type)
|
||||
if len(candidates) > 1 and family is not None:
|
||||
return PendingClarification(
|
||||
field="core_diameter_mm",
|
||||
reason="ambiguous_core_preset",
|
||||
core_family=family,
|
||||
candidates=candidates,
|
||||
requested_changes=deepcopy(requested_changes or {}),
|
||||
)
|
||||
return PendingClarification(
|
||||
"core_diameter_mm",
|
||||
"ambiguous_core_type",
|
||||
requested_changes=deepcopy(requested_changes or {}),
|
||||
)
|
||||
if "article" in ambiguous or any(
|
||||
field in ambiguous for field in ("article_name_hint", "article_number")
|
||||
):
|
||||
resolution = result.get("article_resolution", {})
|
||||
raw_candidates = (
|
||||
resolution.get("candidates", [])
|
||||
if isinstance(resolution, dict)
|
||||
else []
|
||||
)
|
||||
candidates = tuple(
|
||||
ArticleCandidate(
|
||||
article_number=item["article_number"],
|
||||
name=item["name"],
|
||||
)
|
||||
for item in raw_candidates
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("article_number"), str)
|
||||
and isinstance(item.get("name"), str)
|
||||
)
|
||||
return PendingClarification(
|
||||
"article",
|
||||
"ambiguous_article",
|
||||
article_candidates=candidates,
|
||||
requested_changes=deepcopy(requested_changes or {}),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _format_diameter_choices(values: tuple[float, ...]) -> str:
|
||||
labels = [f"{value:g} mm" for value in values]
|
||||
if len(labels) < 2:
|
||||
return labels[0] if labels else ""
|
||||
if len(labels) == 2:
|
||||
return f"{labels[0]} oder {labels[1]}"
|
||||
return f"{', '.join(labels[:-1])} und {labels[-1]}"
|
||||
|
||||
|
||||
def _core_family_names(family: str | None) -> tuple[str, str]:
|
||||
if family == "steel":
|
||||
return "Stahlkern", "Stahlkerne"
|
||||
if family == "pvc":
|
||||
return "PVC-Kern", "PVC-Kerne"
|
||||
return "Kern", "Kerne"
|
||||
|
||||
|
||||
def ambiguous_core_message(pending: PendingClarification) -> str:
|
||||
_, plural = _core_family_names(pending.core_family)
|
||||
choices = _format_diameter_choices(pending.allowed_values)
|
||||
return (
|
||||
f"Es sind mehrere {plural} hinterlegt. "
|
||||
f"Welchen Kerndurchmesser soll ich verwenden: {choices}?"
|
||||
)
|
||||
|
||||
|
||||
def unavailable_core_diameter_message(
|
||||
pending: PendingClarification,
|
||||
value: float,
|
||||
) -> str:
|
||||
singular, _ = _core_family_names(pending.core_family)
|
||||
choices = _format_diameter_choices(pending.allowed_values).replace(
|
||||
" oder ",
|
||||
" und ",
|
||||
)
|
||||
return (
|
||||
f"{value:g} mm ist für einen {singular} nicht hinterlegt. "
|
||||
f"Verfügbar sind {choices}."
|
||||
)
|
||||
|
||||
|
||||
def ambiguous_article_message(pending: PendingClarification) -> str:
|
||||
choices = "\noder\n".join(
|
||||
f"{candidate.article_number} – {' '.join(candidate.name.split())}"
|
||||
for candidate in pending.article_candidates
|
||||
)
|
||||
return (
|
||||
"Ich habe mehrere passende Artikel gefunden. Welchen meinst Du:\n"
|
||||
f"{choices}?"
|
||||
)
|
||||
|
||||
|
||||
def incompatible_clarification_message(field: str) -> str:
|
||||
messages = {
|
||||
"core_diameter_mm": (
|
||||
"Ich benötige den Kerndurchmesser in Millimetern, z. B. 150 mm."
|
||||
),
|
||||
"roll_length_m": "Ich benötige die Rollenlänge in Metern, z. B. 80 m.",
|
||||
"width_m": "Ich benötige die Breite in Metern, z. B. 4,90 m.",
|
||||
"thickness_mm": "Ich benötige die Materialdicke in Millimetern.",
|
||||
"area_weight_g_m2": "Ich benötige das Flächengewicht in g/m².",
|
||||
"article_number": "Bitte gib die genaue Artikelnummer an.",
|
||||
"article": "Bitte wähle eine der genannten Artikelnummern aus.",
|
||||
}
|
||||
return messages.get(field, "Die Antwort passt nicht zur angeforderten Angabe.")
|
||||
|
||||
|
||||
def clarification_message(
|
||||
result: dict[str, Any],
|
||||
pending: PendingClarification | None = None,
|
||||
) -> str:
|
||||
"""Map deterministic result statuses to deterministic German messages."""
|
||||
status = result.get("status")
|
||||
if status == "article_not_found":
|
||||
return "Der angegebene Artikel wurde nicht gefunden."
|
||||
if status == "article_conflict":
|
||||
return (
|
||||
"Artikelnummer und Produktbezeichnung passen nicht zusammen. "
|
||||
"Bitte prüfe die Angaben."
|
||||
)
|
||||
if status == "invalid_parameter":
|
||||
invalid = result.get("invalid", [])
|
||||
if invalid and isinstance(invalid[0], dict):
|
||||
field = invalid[0].get("field", "Eingabe")
|
||||
detail = invalid[0].get("message", "ungültiger Wert")
|
||||
return f"Die Eingabe für {field} ist ungültig: {detail}."
|
||||
return "Die Eingaben sind ungültig. Bitte prüfe die Angaben."
|
||||
|
||||
missing = result.get("missing", [])
|
||||
ambiguous = result.get("ambiguous", [])
|
||||
if "width_m" in missing:
|
||||
return (
|
||||
"Für diesen Artikel ist keine eindeutige Breite verfügbar. "
|
||||
"Welche Breite soll ich verwenden?"
|
||||
)
|
||||
if "core_diameter_mm" in missing:
|
||||
return "Welchen Kerndurchmesser soll ich verwenden?"
|
||||
if "roll_length_m" in missing:
|
||||
return "Welche Rollenlänge soll ich verwenden?"
|
||||
if "thickness_mm" in missing:
|
||||
return (
|
||||
"Für diese Berechnung ist keine Materialdicke verfügbar. "
|
||||
"Bitte gib die Materialdicke an."
|
||||
)
|
||||
if "area_weight_g_m2" in missing:
|
||||
return (
|
||||
"Für die Gewichtsberechnung ist kein Flächengewicht verfügbar. "
|
||||
"Bitte gib das Flächengewicht an."
|
||||
)
|
||||
if "core_type" in ambiguous:
|
||||
if pending is not None and pending.reason == "ambiguous_core_preset":
|
||||
return ambiguous_core_message(pending)
|
||||
request = result.get("request", {})
|
||||
core_type = request.get("core_type") if isinstance(request, dict) else None
|
||||
family = core_family(core_type) if isinstance(core_type, str) else None
|
||||
if family == "steel":
|
||||
return (
|
||||
"Es sind mehrere Stahlkern-Presets vorhanden. "
|
||||
"Bitte gib den Kerndurchmesser an."
|
||||
)
|
||||
if family == "pvc":
|
||||
return (
|
||||
"Es sind mehrere PVC-Kern-Presets vorhanden. "
|
||||
"Bitte gib den Kerndurchmesser an."
|
||||
)
|
||||
return "Der Kerntyp ist nicht eindeutig. Bitte gib den Kerndurchmesser an."
|
||||
if pending is not None and pending.reason == "ambiguous_article":
|
||||
return ambiguous_article_message(pending)
|
||||
if any(
|
||||
field in ambiguous
|
||||
for field in ("article", "article_name_hint", "article_number")
|
||||
):
|
||||
return "Der Artikel ist nicht eindeutig. Bitte gib die genaue Artikelnummer an."
|
||||
return "Für die Berechnung fehlen eindeutige Angaben. Bitte ergänze die Eingaben."
|
||||
|
||||
|
||||
def success_message(result: dict[str, Any], pdf_url: str) -> str:
|
||||
"""Render only authoritative result values using a fixed response template."""
|
||||
article = result.get("resolved_article") or {}
|
||||
calculation = result["calculation"]
|
||||
number = article.get("number") or "ohne Artikelnummer"
|
||||
name = article.get("name") or "ohne Produktbezeichnung"
|
||||
lines = [
|
||||
"Berechnung abgeschlossen.",
|
||||
"",
|
||||
f"Artikel: {name} ({number})",
|
||||
f"Rollenlänge: {calculation['effective_roll_length_m']:.2f} m",
|
||||
]
|
||||
effective_width = result.get("effective_inputs", {}).get("width_m")
|
||||
if isinstance(effective_width, dict) and effective_width.get("value") is not None:
|
||||
lines.append(f"Breite: {effective_width['value']:.2f} m")
|
||||
lines.extend((
|
||||
f"Minimum: {calculation['minimum_diameter_mm']:.1f} mm",
|
||||
f"Mittelwert: {calculation['average_diameter_mm']:.1f} mm",
|
||||
f"Maximum: {calculation['maximum_diameter_mm']:.1f} mm",
|
||||
))
|
||||
if calculation.get("roll_weight_kg") is not None:
|
||||
lines.append(f"Rollengewicht: {calculation['roll_weight_kg']:.1f} kg")
|
||||
for warning in result.get("warnings", []):
|
||||
lines.append(f"Warnung: {warning['title']} – {warning['message']}")
|
||||
for note in result.get("notes", []):
|
||||
lines.append(f"Hinweis: {note}")
|
||||
lines.extend(("", f"PDF: {pdf_url}"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ConversationService:
|
||||
def __init__(
|
||||
self,
|
||||
nlu_client: OllamaNLUClient,
|
||||
*,
|
||||
store: InMemoryConversationStore | None = None,
|
||||
build_info: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.nlu_client = nlu_client
|
||||
self.store = store or InMemoryConversationStore()
|
||||
self.build_info = build_info or {}
|
||||
|
||||
def create_conversation(self) -> str:
|
||||
return self.store.create()
|
||||
|
||||
def conversation_state(self, conversation_id: str) -> ConversationRecord:
|
||||
return self.store.get(conversation_id)
|
||||
|
||||
def report_request(self, report_id: str) -> dict[str, Any]:
|
||||
return self.store.get_report(report_id).request
|
||||
|
||||
def handle_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
message: str,
|
||||
*,
|
||||
include_diagnostics: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
record = self.store.get(conversation_id)
|
||||
clarification: ClarificationInterpretation | None = None
|
||||
nlu: NLUResponse | None = None
|
||||
applied_as_modification = False
|
||||
pending = record.pending_clarification
|
||||
had_valid_result = (
|
||||
isinstance(record.last_result, dict)
|
||||
and record.last_result.get("status") == "success"
|
||||
)
|
||||
if pending is not None:
|
||||
expected_field = pending.field
|
||||
selected_article: ArticleCandidate | None = None
|
||||
if pending.reason == "ambiguous_article":
|
||||
article_number = message.strip()
|
||||
selected_article = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in pending.article_candidates
|
||||
if candidate.article_number == article_number
|
||||
),
|
||||
None,
|
||||
)
|
||||
clarification = ClarificationInterpretation(
|
||||
"parsed" if selected_article is not None else "incompatible",
|
||||
expected_field,
|
||||
selected_article.article_number if selected_article else None,
|
||||
)
|
||||
else:
|
||||
clarification = parse_clarification_reply(message, expected_field)
|
||||
if clarification.status != "parsed":
|
||||
response = {
|
||||
"status": "needs_clarification",
|
||||
"message": (
|
||||
ambiguous_article_message(pending)
|
||||
if pending.reason == "ambiguous_article"
|
||||
else incompatible_clarification_message(expected_field)
|
||||
),
|
||||
"clarification": clarification.to_dict(),
|
||||
"pending_clarification": pending.to_dict(),
|
||||
"result": record.last_result,
|
||||
}
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
|
||||
if record.calculation_state is None:
|
||||
return {
|
||||
"status": "needs_calculation",
|
||||
"message": (
|
||||
"Es gibt noch keine Berechnung, die ergänzt werden kann. "
|
||||
"Bitte starte zuerst eine neue Berechnung."
|
||||
),
|
||||
}
|
||||
if pending.reason == "ambiguous_article":
|
||||
changes = dict(pending.requested_changes)
|
||||
changes["article_number"] = clarification.value
|
||||
else:
|
||||
changes = dict(pending.requested_changes)
|
||||
changes[expected_field] = clarification.value
|
||||
if pending.reason == "ambiguous_core_preset":
|
||||
preset = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in pending.candidates
|
||||
if math.isclose(
|
||||
candidate.diameter_mm,
|
||||
clarification.value,
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if preset is None:
|
||||
response = {
|
||||
"status": "needs_clarification",
|
||||
"message": unavailable_core_diameter_message(
|
||||
pending,
|
||||
clarification.value,
|
||||
),
|
||||
"clarification": {
|
||||
"status": "invalid_value",
|
||||
"field": pending.field,
|
||||
"value": clarification.value,
|
||||
},
|
||||
"pending_clarification": pending.to_dict(),
|
||||
"result": record.last_result,
|
||||
}
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
changes.update({
|
||||
"core_type": preset.label,
|
||||
"core_diameter_mm": preset.diameter_mm,
|
||||
})
|
||||
elif expected_field == "core_diameter_mm":
|
||||
current_request = record.calculation_state.get("request", {})
|
||||
current_core_type = current_request.get("core_type")
|
||||
if current_core_type is not None:
|
||||
changes["core_type"] = current_core_type
|
||||
result = modify_calculation(
|
||||
record.calculation_state,
|
||||
changes,
|
||||
build_info=self.build_info,
|
||||
)
|
||||
applied_as_modification = True
|
||||
else:
|
||||
try:
|
||||
nlu = self.nlu_client.interpret(
|
||||
message,
|
||||
has_state=record.calculation_state is not None,
|
||||
expected_fields=(),
|
||||
current_state=_current_nlu_context(record),
|
||||
)
|
||||
except NLUError as error:
|
||||
return {
|
||||
"status": "nlu_error",
|
||||
"error": error.__class__.__name__,
|
||||
"message": (
|
||||
"Die Spracheingabe konnte nicht sicher verarbeitet werden. "
|
||||
"Bitte versuche es erneut oder formuliere die Angaben expliziter."
|
||||
),
|
||||
}
|
||||
|
||||
interpretation = nlu.interpretation
|
||||
if interpretation.intent == "unsupported":
|
||||
response = {
|
||||
"status": "unsupported",
|
||||
"message": (
|
||||
"Diese Anfrage wird derzeit nicht unterstützt. Möglich sind neue "
|
||||
"Rollendurchmesser-Berechnungen und Änderungen einer bestehenden "
|
||||
"Berechnung."
|
||||
),
|
||||
}
|
||||
return self._with_nlu(response, nlu, include_diagnostics)
|
||||
|
||||
followup_changes = _new_calculation_followup_changes(
|
||||
interpretation,
|
||||
record,
|
||||
message,
|
||||
)
|
||||
if followup_changes is not None:
|
||||
result = modify_calculation(
|
||||
record.calculation_state,
|
||||
followup_changes,
|
||||
build_info=self.build_info,
|
||||
)
|
||||
applied_as_modification = True
|
||||
elif interpretation.intent == "new_calculation":
|
||||
result = calculate_roll(
|
||||
_calculation_request(interpretation),
|
||||
build_info=self.build_info,
|
||||
)
|
||||
else:
|
||||
if record.calculation_state is None:
|
||||
response = {
|
||||
"status": "needs_calculation",
|
||||
"message": (
|
||||
"Es gibt noch keine Berechnung, die geändert werden kann. "
|
||||
"Bitte starte zuerst eine neue Berechnung."
|
||||
),
|
||||
}
|
||||
return self._with_nlu(response, nlu, include_diagnostics)
|
||||
changes = interpretation.changes or {}
|
||||
current_request = record.calculation_state.get("request", {})
|
||||
current_article = current_request.get("article_number")
|
||||
different_article = (
|
||||
"article_number" in changes
|
||||
and changes["article_number"] != current_article
|
||||
) or "article_name_hint" in changes
|
||||
if different_article:
|
||||
new_request = {
|
||||
"intent": "calculate_roll_diameter",
|
||||
**{
|
||||
field: changes[field]
|
||||
for field in ("article_number", "article_name_hint")
|
||||
if field in changes
|
||||
},
|
||||
}
|
||||
result = calculate_roll(
|
||||
new_request,
|
||||
build_info=self.build_info,
|
||||
)
|
||||
else:
|
||||
result = modify_calculation(
|
||||
record.calculation_state,
|
||||
changes,
|
||||
build_info=self.build_info,
|
||||
)
|
||||
applied_as_modification = True
|
||||
|
||||
if clarification is not None and result.get("status") == "invalid_parameter":
|
||||
response = {
|
||||
"status": "needs_clarification",
|
||||
"message": incompatible_clarification_message(clarification.field),
|
||||
"clarification": clarification.to_dict(),
|
||||
"result": result,
|
||||
}
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
|
||||
pending_changes = None
|
||||
if applied_as_modification:
|
||||
if clarification is not None:
|
||||
pending_changes = changes
|
||||
elif nlu is not None and nlu.interpretation.intent == "modify_calculation":
|
||||
pending_changes = nlu.interpretation.changes
|
||||
else:
|
||||
pending_changes = followup_changes
|
||||
new_pending = _pending_clarification(
|
||||
result,
|
||||
requested_changes=pending_changes,
|
||||
)
|
||||
preserve_valid_state = (
|
||||
had_valid_result
|
||||
and (
|
||||
(
|
||||
new_pending is not None
|
||||
and new_pending.reason == "ambiguous_article"
|
||||
)
|
||||
or (
|
||||
applied_as_modification
|
||||
and (
|
||||
(
|
||||
new_pending is not None
|
||||
and new_pending.reason
|
||||
in {
|
||||
"ambiguous_core_preset",
|
||||
"ambiguous_core_type",
|
||||
"ambiguous_article",
|
||||
}
|
||||
)
|
||||
or result.get("status")
|
||||
in {
|
||||
"invalid_parameter",
|
||||
"article_not_found",
|
||||
"article_conflict",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if not preserve_valid_state:
|
||||
state = result.get("state")
|
||||
if isinstance(state, dict):
|
||||
record.calculation_state = state
|
||||
record.last_result = result
|
||||
record.pending_clarification = new_pending
|
||||
self.store.save(conversation_id, record)
|
||||
|
||||
if result.get("status") != "success":
|
||||
response = {
|
||||
"status": result.get("status", "invalid_parameter"),
|
||||
"message": clarification_message(result, new_pending),
|
||||
"result": result,
|
||||
}
|
||||
if new_pending is not None:
|
||||
pending_context = new_pending.to_dict()
|
||||
response["pending_clarification"] = pending_context
|
||||
if new_pending.reason == "ambiguous_article":
|
||||
response.update({
|
||||
"field": pending_context["field"],
|
||||
"reason": pending_context["reason"],
|
||||
"candidates": pending_context["candidates"],
|
||||
})
|
||||
if clarification is not None:
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
return self._with_nlu(response, nlu, include_diagnostics)
|
||||
|
||||
report_id = self.store.add_report(conversation_id, result["request"])
|
||||
pdf_url = f"/api/conversations/reports/{report_id}.pdf"
|
||||
response = {
|
||||
"status": "success",
|
||||
"message": success_message(result, pdf_url),
|
||||
"result": result,
|
||||
"pdf": {"report_id": report_id, "url": pdf_url},
|
||||
}
|
||||
if clarification is not None:
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
return self._with_nlu(response, nlu, include_diagnostics)
|
||||
|
||||
@staticmethod
|
||||
def _with_nlu(
|
||||
response: dict[str, Any],
|
||||
nlu: NLUResponse,
|
||||
include_diagnostics: bool,
|
||||
) -> dict[str, Any]:
|
||||
response["interpretation"] = nlu.interpretation.to_dict()
|
||||
response["nlu"] = {
|
||||
"model": nlu.model,
|
||||
"latency_ms": round(nlu.latency_ms, 1),
|
||||
}
|
||||
if include_diagnostics:
|
||||
response["nlu"]["raw_model_json"] = nlu.raw_model_json
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _with_clarification(
|
||||
response: dict[str, Any],
|
||||
clarification: ClarificationInterpretation,
|
||||
include_diagnostics: bool,
|
||||
) -> dict[str, Any]:
|
||||
response["interpretation"] = clarification.to_dict()
|
||||
response["nlu"] = {
|
||||
"model": "deterministic_clarification_parser",
|
||||
"latency_ms": 0.0,
|
||||
}
|
||||
if include_diagnostics:
|
||||
response["nlu"]["raw_model_json"] = None
|
||||
return response
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
"""Canonical core presets shared by the UI and deterministic services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CorePreset:
|
||||
key: str
|
||||
diameter_mm: float
|
||||
material: str
|
||||
label: str
|
||||
aliases: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def ui_diameter(self) -> str:
|
||||
return f"{self.diameter_mm:g}"
|
||||
|
||||
|
||||
CORE_PRESETS = (
|
||||
CorePreset(
|
||||
key="steel_133",
|
||||
diameter_mm=133.0,
|
||||
material="steel",
|
||||
label="133mm Stahl",
|
||||
aliases=(
|
||||
"133 mm Stahl",
|
||||
"133mm Stahlkern",
|
||||
"133 mm Stahlkern",
|
||||
"133mm steel",
|
||||
"133 mm steel",
|
||||
"133mm steel core",
|
||||
"133 mm steel core",
|
||||
),
|
||||
),
|
||||
CorePreset(
|
||||
key="pvc_150",
|
||||
diameter_mm=150.0,
|
||||
material="pvc",
|
||||
label="150mm PVC",
|
||||
aliases=(
|
||||
"150 mm PVC",
|
||||
"150mm PVC-Kern",
|
||||
"150 mm PVC-Kern",
|
||||
"150mm plastic core",
|
||||
"150 mm plastic core",
|
||||
),
|
||||
),
|
||||
CorePreset(
|
||||
key="pvc_168",
|
||||
diameter_mm=168.0,
|
||||
material="pvc",
|
||||
label="168mm PVC",
|
||||
aliases=(
|
||||
"168 mm PVC",
|
||||
"168mm PVC-Kern",
|
||||
"168 mm PVC-Kern",
|
||||
"168mm plastic core",
|
||||
"168 mm plastic core",
|
||||
),
|
||||
),
|
||||
CorePreset(
|
||||
key="steel_194",
|
||||
diameter_mm=194.0,
|
||||
material="steel",
|
||||
label="194mm Stahl",
|
||||
aliases=(
|
||||
"194 mm Stahl",
|
||||
"194mm Stahlkern",
|
||||
"194 mm Stahlkern",
|
||||
"194mm steel",
|
||||
"194 mm steel",
|
||||
"194mm steel core",
|
||||
"194 mm steel core",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
CORE_FAMILY_ALIASES = {
|
||||
"stahl": "steel",
|
||||
"stahlkern": "steel",
|
||||
"stahl kern": "steel",
|
||||
"steel": "steel",
|
||||
"steel core": "steel",
|
||||
"pvc": "pvc",
|
||||
"pvc kern": "pvc",
|
||||
"pvc core": "pvc",
|
||||
"kunststoff": "pvc",
|
||||
"kunststoffkern": "pvc",
|
||||
"kunststoff kern": "pvc",
|
||||
"plastic": "pvc",
|
||||
"plastic core": "pvc",
|
||||
}
|
||||
|
||||
|
||||
def normalize_core_reference(value: str) -> str:
|
||||
normalized = value.casefold().strip()
|
||||
normalized = re.sub(r"[-_]", " ", normalized)
|
||||
return re.sub(r"\s+", " ", normalized)
|
||||
|
||||
|
||||
def core_family(value: str) -> str | None:
|
||||
return CORE_FAMILY_ALIASES.get(normalize_core_reference(value))
|
||||
|
||||
|
||||
def matching_core_presets(value: str) -> tuple[CorePreset, ...]:
|
||||
"""Return exact preset aliases or all presets in an explicit family alias."""
|
||||
normalized = normalize_core_reference(value)
|
||||
exact = tuple(
|
||||
preset
|
||||
for preset in CORE_PRESETS
|
||||
if normalized
|
||||
in {
|
||||
normalize_core_reference(preset.label),
|
||||
*(normalize_core_reference(alias) for alias in preset.aliases),
|
||||
}
|
||||
)
|
||||
if exact:
|
||||
return exact
|
||||
material = CORE_FAMILY_ALIASES.get(normalized)
|
||||
if material is None:
|
||||
return ()
|
||||
return tuple(preset for preset in CORE_PRESETS if preset.material == material)
|
||||
@@ -0,0 +1,16 @@
|
||||
Production line;Aliasses;Max. Rollweight [kg];Max Rolldiameter [mm];Min Rolldiameter [mm];allowed core diameters [mm];Max linespeed [m/min];Min linespeed [m/min];max. Product Width;min. Product Width;Location
|
||||
Bento 1;Bento1, B1;2501;876;300;150, 170, 200, 235;6;0,9;5;4,7;Espelkamp (Germany)
|
||||
Bento 2;Bento2, B2;2501;1000;250;150, 170, 200, 235;6;0,9;5;4,7;Selangor (Malaysia)
|
||||
K1;K 1;800;1100;200;130, 150, 170;20;1;6,1;4;Selangor (Malaysia)
|
||||
K3/4;K 34, K 3/4;800;1100;200;130, 150, 170;15;1;6,1;4;Espelkamp (Germany)
|
||||
K5;K 5;800;1200;200;130, 150, 170;20;1;6,1;4;Adorf (Germany)
|
||||
K6;K 6;800;1100;200;130, 150, 170;20;1;6,1;4;Selangor (Malaysia)
|
||||
K7;K 7;700;1100;300;130, 150, 170;40;0,6;6,1;4;Espelkamp (Germany)
|
||||
E1;E 1;2500;1000;200;150, 170;10;0,6;7,5;7,5;Espelkamp (Germany)
|
||||
E4;E 4;2000;900;200;150, 170;10;0,5;5,1;5,1;Espelkamp (Germany)
|
||||
Gitter 1;G1, G 1;400;600;200;140;;;4,75;4;Adorf (Germany)
|
||||
Gitter 2;G2, G 2;400;600;200;140;;;4,75;4;Adorf (Germany)
|
||||
Gitter 3;G3, G 3;400;600;200;140;;;4,75;4;Adorf (Germany)
|
||||
Gitter 4;G4, G 4;400;600;200;140;;;4,75;4;Adorf (Germany)
|
||||
Secudrain 1/2;Sec 1/2, Secudrain 12, Sec 12;400;800;200;120;;;4;2;Adorf (Germany)
|
||||
Secudrain 3;Sec 3;400;800;200;120;;;2,1;1,8;Adorf (Germany)
|
||||
|
@@ -1,5 +1,7 @@
|
||||
# 🚀 RollCalc V14 + QoL Update - Deployment Guide
|
||||
|
||||
> Current repository note: direct roll-diameter calculations are generated server-side by `roll_calculation.py`, and PDF reports are rendered by `pdf_report.py`. Deploy both beside `app.py`, install the unchanged `requirements.txt`, and ensure nginx forwards authenticated `POST /api/calculations/roll`, `POST /api/calculations/roll/modify`, and `POST /api/reports/roll-calculation.pdf` requests to Flask. No browser, system PDF/font package, or additional Python dependency is required; the generated report is one A4 page under normal calculator conditions.
|
||||
|
||||
## 📦 Archive Contents
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Roll Calculator Architecture
|
||||
|
||||
## Authority Boundary
|
||||
|
||||
Natural-language clients may describe or modify a calculation request, but engineering results are generated exclusively by the deterministic Roll Calculator core.
|
||||
|
||||
External callers can provide decision inputs only. `CalculationRequest.from_dict()` rejects diameter results, roll-weight results, warnings, and complete calculation objects. Missing decision-relevant inputs produce clarification instead of defaults, and article/core conflicts are returned explicitly.
|
||||
|
||||
## Direct Roll-Diameter Flow
|
||||
|
||||
```text
|
||||
Browser or future tool client
|
||||
|
|
||||
| CalculationRequest (inputs only)
|
||||
v
|
||||
ArticleRepository.resolve()
|
||||
|
|
||||
v
|
||||
roll_calculation.calculate_roll()
|
||||
|
|
||||
| CalculationResult
|
||||
+----------------------> browser rendering
|
||||
|
|
||||
+--> report_from_calculation_result()
|
||||
|
|
||||
v
|
||||
render_roll_report()
|
||||
```
|
||||
|
||||
`roll_calculation.py` is authoritative for the direct roll-diameter range, effective roll length, optional roll weight, known-core resolution, and direct-flow forklift warnings. Canonical core presets live in `core_presets.py`; both the Jinja-rendered browser buttons and `roll_calculation.py` consume that same ordered tuple. The active browser direct-diameter mode calls `/api/calculations/roll`; it has no independent implementation of that diameter formula.
|
||||
|
||||
The PDF endpoint accepts the same calculation request, recalculates it through the core, then adapts the authoritative result to the established one-page PDF layout. It does not accept calculated values from the browser.
|
||||
|
||||
## Production-Machine Feasibility
|
||||
|
||||
`config/machines.yaml` is the runtime source for configured production-machine
|
||||
limits; `data/source/LineLimitations_RollCalc.csv` is retained as the reference
|
||||
source document. `machine_constraints.py` loads and validates the YAML through
|
||||
`MachineRepository`, resolves only stable IDs/names/configured aliases, and
|
||||
evaluates independent constraints through `check_production_feasibility()`.
|
||||
|
||||
V1 compares material-only roll weight, nominal/average diameter, calculated
|
||||
maximum diameter, core diameter, and product width. All configured V1 inputs
|
||||
are required before a feasible result may be claimed. Nominal diameter is used
|
||||
for a configured minimum; an average diameter over the maximum fails, whereas
|
||||
an average fit whose calculated maximum exceeds the maximum is a retained
|
||||
warning. Line speed remains configured but is outside the initial roll-only
|
||||
scope. Flask and MCP are thin adapters; no machine constraints are duplicated
|
||||
in those layers or in browser code.
|
||||
|
||||
`calculate_machine_max_product_length()` composes the same domains for target
|
||||
length questions. It resolves the machine only through `MachineRepository`,
|
||||
uses `calculate_product_length()` with the configured maximum diameter, and
|
||||
maps its average length to the nominal maximum and its minimum length to the
|
||||
conservative/no-warning maximum. Material-weight maxima use the shared
|
||||
`calculate_material_length_for_weight()` inverse; final material weights use
|
||||
`calculate_material_weight()`. The domain chooses governing constraints,
|
||||
including deterministic ties, and verifies selected lengths through the
|
||||
existing direct roll and feasibility services. It never resolves articles,
|
||||
reads YAML in adapters, duplicates roll mathematics, applies a manufacturing
|
||||
increment, or implements conditional production rules. Flask and MCP expose
|
||||
thin adapters; the browser UI remains unchanged.
|
||||
|
||||
## Constrained Natural-Language Flow
|
||||
|
||||
```text
|
||||
German/English utterance
|
||||
|
|
||||
v
|
||||
OllamaNLUClient (qwen3.5:35B-A3B by default)
|
||||
|
|
||||
| strict input-only NLU JSON
|
||||
v
|
||||
application schema validation
|
||||
|
|
||||
+--> new_calculation --> calculate_roll()
|
||||
|
|
||||
+--> modify_calculation --> modify_calculation()
|
||||
|
|
||||
+--> unsupported
|
||||
|
|
||||
v
|
||||
deterministic response/clarification template
|
||||
|
|
||||
+--> input-only temporary report reference
|
||||
|
|
||||
v
|
||||
calculate_roll() --> existing PDF renderer
|
||||
```
|
||||
|
||||
The local LLM is an NLU adapter only. Engineering calculations remain deterministic. For continuity, Ollama receives a minimal summary of the last successful input state (article reference, length, width, core, and weight-request flag), but no formula, product/master-data dataset, warning rule, calculated result, or PDF payload. Its output is constrained to `new_calculation`, a multi-field `modify_calculation`, or `unsupported`, then independently validated by `ollama_nlu.validate_nlu_payload()`.
|
||||
|
||||
`ConversationService` owns structured state and does not replay a transcript to reconstruct technical parameters. It records the current `CalculationState`, last deterministic result, and a typed pending clarification. Ambiguous core-family context contains the canonical family and candidate `CorePreset` objects, from which allowed diameters and deterministic messages are derived. Failed candidate validation leaves the prior valid state and pending context unchanged. All clarification and success prose is produced by fixed application templates.
|
||||
|
||||
If a successful state exists and the model returns an identifier-free `new_calculation` containing only explicit modification-like values, `ConversationService` applies those non-null values as a narrow patch to the current state. Null/omitted values never erase inherited inputs, and `include_roll_weight` is added only when true. A different article identifier or an explicit new-calculation phrase bypasses inheritance. Failed NLU or deterministic validation preserves the previous valid state.
|
||||
|
||||
Successful responses create a random report reference that stores the validated input request only. Downloading the report performs a fresh authoritative calculation and uses the same `report_from_calculation_result()` / `render_roll_report()` path as the browser PDF endpoint.
|
||||
|
||||
The current `InMemoryConversationStore` is an MVP boundary. It is process-local, is cleared on restart, is not shared across WSGI workers, has no retention policy, and should be replaced before a multi-process OpenWebUI deployment.
|
||||
|
||||
## Models and Statuses
|
||||
|
||||
`CalculationRequest` contains explicit request fields such as article identifier/name hint, roll length, width, thickness/stddev, area weight, core diameter/type, category/site, and whether optional weight is requested.
|
||||
|
||||
`CalculationState` wraps that request for conversational workflows and is included in calculation responses. `modify_calculation()` applies an explicit field patch, validates the resulting request, and recalculates exactly once. A failed or ambiguous patch does not replace the prior successful state; pending clarification stores the complete requested patch structurally and combines it with the selected clarification before that one recalculation. Coupled core changes clear the old counterpart so a phrase such as `steel` cannot silently retain an unrelated diameter.
|
||||
|
||||
Successful results include:
|
||||
|
||||
- resolved article;
|
||||
- effective inputs with `user`, `article_master_data`, or other explicit provenance;
|
||||
- effective roll length;
|
||||
- minimum, average, and maximum diameter;
|
||||
- optional roll weight;
|
||||
- warnings and notes;
|
||||
- calculator/build provenance.
|
||||
|
||||
Non-success statuses are structured as `article_not_found`, `article_conflict`, `needs_clarification`, or `invalid_parameter`. Missing and ambiguous fields are separate arrays.
|
||||
|
||||
## Article and Core Resolution
|
||||
|
||||
Article numbers remain strings and exact article-number matching takes precedence. A supplied name hint is checked conservatively. Name-only resolution first uses normalized full/base-name matching (case, whitespace, punctuation, `R501`/`R 501`/`R-501`, trailing dimensions, and parenthetical descriptors). That existing normalization contains the only supported bidirectional product-family aliases: `Stex`/`Secutex`, `Bfix`/`Bentofix`, and `Sgrid`/`Secugrid`; aliases are token-bounded and map to the abbreviated canonical token before matching. The optional fuzzy stage ranks repository candidates only; it resolves only a unique, high-confidence candidate with a safe margin and otherwise returns a clarification. Duplicate master-data records for the same exact article number also require clarification instead of being selected by file order. Width extraction is limited to one recognized, unambiguous dimension in the master-data name and records master-data provenance.
|
||||
|
||||
Known cores are defined once in `core_presets.py`: 133 mm steel, 150 mm PVC, 168 mm PVC, and 194 mm steel. `app.py` passes this ordered tuple to the active Jinja template, and the headless resolver imports the same tuple. Complete labels and a finite explicit alias set resolve deterministically to a canonical label and diameter. Generic `steel`/`Stahlkern` and `PVC`/plastic descriptions remain ambiguous because each existing family has two presets. Custom positive diameters remain permitted because the existing UI permits them.
|
||||
|
||||
## Scope Boundary
|
||||
|
||||
The conversational adapter currently covers the normal direct roll-diameter operation and its optional weight/warnings/PDF flow. The product-length, target-diameter, extrapolation, and load-optimizer modes remain client-side and unsupported by NLU. No RAG, embedding, vector database, agent framework, autonomous loop, or arbitrary tool execution is present.
|
||||
|
||||
## OpenWebUI Presentation Boundary
|
||||
|
||||
```text
|
||||
OpenWebUI chat (RollCalc Pipe)
|
||||
|
|
||||
| user message + application-owned chat/session key
|
||||
v
|
||||
OpenWebUI Pipe in-memory mapping
|
||||
|
|
||||
| existing RollCalc conversation_id
|
||||
v
|
||||
POST /api/conversations/<id>/messages
|
||||
|
|
||||
+--> deterministic user-facing message
|
||||
|
|
||||
+--> validated relative report path
|
||||
|
|
||||
v
|
||||
browser-public RollCalc URL + existing authenticated PDF endpoint
|
||||
```
|
||||
|
||||
`integrations/openwebui/rollcalc_pipe.py` is intentionally a Pipe instead of an
|
||||
LLM Tool. The Pipe is itself the selectable OpenWebUI model and performs one
|
||||
direct asynchronous API call, so OpenWebUI cannot decide whether to invoke the
|
||||
calculator or generate an alternative engineering answer. It contains no
|
||||
calculation, resolver, clarification, core-preset, warning, or report-rendering
|
||||
logic.
|
||||
|
||||
The process-local mapping key combines the OpenWebUI user ID and chat ID. A
|
||||
per-chat lock serializes clarification/follow-up messages while separate chats
|
||||
remain independent. Missing stable identifiers are rejected rather than mapped
|
||||
to a global session. Unknown RollCalc conversations are discarded without
|
||||
replaying the current follow-up against empty state.
|
||||
|
||||
Internal API and external report routing are configured separately. API Basic
|
||||
Auth is server-side; report Markdown links contain no credentials and retain
|
||||
the existing browser-facing Basic-Auth challenge. The MVP does not provide
|
||||
shared SSO, persistent/cross-worker mappings, report tokens, or streaming.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added deterministic, token-bounded product-family normalization in the article resolver for `Stex`/`Secutex`, `Bfix`/`Bentofix`, and `Sgrid`/`Secugrid`.
|
||||
- Added a minimal OpenWebUI Pipe that maps each OpenWebUI user/chat to the
|
||||
existing RollCalc conversation API, renders only deterministic messages, and
|
||||
exposes validated reports through a configurable browser-public URL.
|
||||
- Added friendly adapter errors, per-chat asynchronous request serialization,
|
||||
server-side Basic Auth without credential-bearing links, deployment/demo
|
||||
documentation, and mocked OpenWebUI Pipe tests without a RollCalc dependency.
|
||||
- Added a direct, dependency-free Ollama `/api/chat` client, defaulting to the configurable `qwen3.5:35B-A3B` model, with temperature `0`, thinking disabled, timeout handling, and JSON-schema-constrained output.
|
||||
- Added strict `new_calculation`, multi-field `modify_calculation`, and `unsupported` NLU validation; calculated and unknown fields are rejected.
|
||||
- Added process-local conversation state, deterministic German clarification/result templates, and temporary input-only PDF references.
|
||||
- Added authenticated conversation creation/message/report routes and a `python -m rollcalc_chat` terminal demo with optional diagnostics.
|
||||
- Added mocked Ollama, schema, conversational workflow, API, and authoritative conversational PDF tests; normal tests do not require Ollama.
|
||||
- Added `roll_calculation.py` as the authoritative headless service for direct diameter ranges, optional roll weight, article/core resolution, and warnings.
|
||||
- Added JSON-serializable calculation request/state/result models, structured clarification/failure statuses, and controlled mutation/recalculation.
|
||||
- Reject caller-supplied result fields in both requests and calculation state, and require clarification for duplicate exact article identifiers.
|
||||
- Added authenticated calculation and modification API boundaries for future tool use; no LLM integration was added.
|
||||
- Routed the active direct-diameter UI and PDF generation through the deterministic service and rejected caller-supplied engineering result fields.
|
||||
- Added archive-safe PDF filenames with date and `HHMMSS` time.
|
||||
- Added an authenticated **Download PDF** action for valid direct roll-diameter results.
|
||||
- Added compact, one-page A4 reports with article data, material inputs, minimum/average/maximum diameter, optional roll weight, active forklift warnings/notes, generation time, and build metadata.
|
||||
- Embedded repeated diagonal `INTERNAL USE ONLY` marks directly in the PDF page content stream. This is an internal-use marking and deterrent, not DRM or a tamper-proof control.
|
||||
- Added strict report-model validation and filesystem-safe archive filenames.
|
||||
- Added PDF model and endpoint tests without adding a new runtime dependency.
|
||||
- Renamed the PDF result section to **Calculation Results** and added the effective roll length as a fourth primary result card.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# OpenWebUI RollCalc Pipe
|
||||
|
||||
`rollcalc_pipe.py` registers **RollCalc Assistant** as a selectable OpenWebUI
|
||||
Pipe model. It does not call an OpenWebUI chat model and contains no engineering
|
||||
logic. Each user message is forwarded to the existing authenticated RollCalc
|
||||
conversation API, and only its deterministic `message` plus a safe report link
|
||||
is rendered.
|
||||
|
||||
## Deployment discovery
|
||||
|
||||
The OpenWebUI runtime was not visible from the coding sandbox: the Docker socket
|
||||
was inaccessible, no native OpenWebUI files/processes were visible, and the
|
||||
usual local ports were not reachable. Run these commands directly on
|
||||
`fertigungski` before enabling the Pipe:
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
|
||||
docker inspect <openwebui-container> --format '{{json .NetworkSettings.Networks}}'
|
||||
docker exec <openwebui-container> python -c \
|
||||
'from importlib.metadata import version; print(version("open-webui"))'
|
||||
```
|
||||
|
||||
For a native installation, use:
|
||||
|
||||
```bash
|
||||
systemctl list-units --type=service | grep -i open-webui
|
||||
python3 -c 'from importlib.metadata import version; print(version("open-webui"))'
|
||||
```
|
||||
|
||||
Also verify the actual route in both network contexts:
|
||||
|
||||
```bash
|
||||
# From the OpenWebUI container/server namespace
|
||||
curl -u "$ROLLCALC_USERNAME:$ROLLCALC_PASSWORD" \
|
||||
"$ROLLCALC_API_BASE_URL/api/health"
|
||||
|
||||
# From a demo user's browser/network (run on a representative workstation)
|
||||
curl -I "$ROLLCALC_PUBLIC_BASE_URL/api/conversations/reports/not-found.pdf"
|
||||
```
|
||||
|
||||
An authenticated `404` for the deliberately invalid report is sufficient for
|
||||
the second network check. A `401` confirms that the public route exists but the
|
||||
browser has not authenticated yet.
|
||||
|
||||
## Install and enable
|
||||
|
||||
1. Open **Admin Panel → Workspace → Functions** in OpenWebUI.
|
||||
2. Create/import a Function using the complete contents of
|
||||
`integrations/openwebui/rollcalc_pipe.py`.
|
||||
3. Save it and enable the Function.
|
||||
4. Open its Valves and configure the values below.
|
||||
5. Start a new chat and select **RollCalc Assistant** as the model.
|
||||
|
||||
The Pipe uses `pydantic` and asynchronous `httpx`, which are OpenWebUI runtime
|
||||
dependencies; RollCalc's Python environment gains no package dependency.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables provide defaults; Function Valves can override them in
|
||||
OpenWebUI:
|
||||
|
||||
| Variable / Valve | Purpose |
|
||||
| --- | --- |
|
||||
| `ROLLCALC_API_BASE_URL` | RollCalc base URL reachable by the OpenWebUI server/container. |
|
||||
| `ROLLCALC_PUBLIC_BASE_URL` | RollCalc base URL reachable by the user's browser. |
|
||||
| `ROLLCALC_USERNAME` | Basic-Auth user used by the Pipe for API calls. |
|
||||
| `ROLLCALC_PASSWORD` | Basic-Auth password used by the Pipe for API calls. |
|
||||
| `ROLLCALC_OPENWEBUI_TIMEOUT_SECONDS` | End-to-end request timeout; default `90`. |
|
||||
| `ROLLCALC_OPENWEBUI_DEBUG` | Safe console diagnostics; default `false`. |
|
||||
|
||||
Do not put credentials into either URL. Store the password in the protected
|
||||
OpenWebUI Valve/environment configuration and do not commit it.
|
||||
|
||||
`localhost` inside a container refers to that container. Typical internal URLs
|
||||
are:
|
||||
|
||||
- `http://rollcalc:5000` when both services share a Docker network;
|
||||
- `http://host.docker.internal:5000` when RollCalc runs on the host and the
|
||||
Linux container has an explicit `host-gateway` mapping;
|
||||
- the server's real internal DNS name when the services run on separate hosts.
|
||||
|
||||
The public URL must be the externally reachable origin/path, for example
|
||||
`https://fertigungski.example/rollcalc`. It must never be an internal-only
|
||||
container hostname.
|
||||
|
||||
Qwen remains configured on the RollCalc service, not in the Pipe. For the
|
||||
validated local model, start RollCalc with:
|
||||
|
||||
```bash
|
||||
export ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B
|
||||
```
|
||||
|
||||
The existing `ROLLCALC_OLLAMA_URL`, timeout, temperature `0`, `think=false`,
|
||||
and structured-output settings remain authoritative in `ollama_nlu.py`.
|
||||
|
||||
## Session and authentication behavior
|
||||
|
||||
The Pipe maps `(OpenWebUI user ID, OpenWebUI chat ID)` to one RollCalc
|
||||
`conversation_id`. It serializes messages within one chat and allows different
|
||||
chats to wait on Qwen independently. The mapping is process-local and is lost
|
||||
when OpenWebUI reloads the Function or restarts. RollCalc's own conversation and
|
||||
report stores are also process-local and are lost when RollCalc restarts.
|
||||
|
||||
If RollCalc reports an expired/unknown conversation, the Pipe removes the
|
||||
mapping and asks the user to repeat a complete initial request. It deliberately
|
||||
does not replay a follow-up into an empty technical state.
|
||||
|
||||
API calls authenticate server-to-server with the configured Basic-Auth account.
|
||||
PDF links never contain those credentials. The existing PDF endpoint therefore
|
||||
prompts the browser for RollCalc Basic Auth on first access. For the demo, open
|
||||
and authenticate to `ROLLCALC_PUBLIC_BASE_URL` once in the same browser. Shared
|
||||
SSO or a short-lived download token is not implemented.
|
||||
|
||||
Because all Pipe API calls use one service account, RollCalc's current access
|
||||
log records that service identity rather than the individual OpenWebUI user.
|
||||
|
||||
## Demo procedure
|
||||
|
||||
For a repository-local validation, use separate terminals:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
ollama serve
|
||||
|
||||
# Terminal 2
|
||||
cd /opt/git-projects/RollCalcPython
|
||||
ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B .venv/bin/python app.py
|
||||
```
|
||||
|
||||
Start/restart the already-discovered OpenWebUI deployment using its actual
|
||||
container or native service name; do not create a second deployment merely for
|
||||
the Pipe. Configure and enable the Function as described above. Then open
|
||||
`ROLLCALC_PUBLIC_BASE_URL` once and complete its Basic-Auth prompt.
|
||||
|
||||
Select **RollCalc Assistant** in OpenWebUI and use one chat for the entire
|
||||
sequence:
|
||||
|
||||
```text
|
||||
Welchen Durchmesser hat Bentofix NSP 4900, Artikelnummer 180205 bei 65 m Länge?
|
||||
150 mm
|
||||
Bitte ändere den Kern auf einen Stahlkern.
|
||||
194 mm
|
||||
Wie schwer ist die berechnete Rolle?
|
||||
Ändere die Länge auf 80 m.
|
||||
```
|
||||
|
||||
Select one of the candidate diameters actually offered by RollCalc if the preset
|
||||
list differs. Verify that each successful recalculation has a new **PDF
|
||||
herunterladen** link, that the weight remains visible after the final length
|
||||
change, and that opening the links produces authenticated PDFs.
|
||||
|
||||
## Known demo limitations
|
||||
|
||||
- There is no streaming token output; the Pipe returns when the local Qwen plus
|
||||
deterministic RollCalc request completes.
|
||||
- Session mappings, RollCalc conversation state, and reports are in memory only.
|
||||
- Multiple OpenWebUI or RollCalc workers do not share state.
|
||||
- Browser and server-side Basic-Auth sessions are separate.
|
||||
- RollCalc audit entries identify the configured Pipe service account, not the
|
||||
originating OpenWebUI user.
|
||||
- OpenWebUI direct API calls without a stable user/chat identifier are rejected
|
||||
instead of sharing or guessing a session.
|
||||
- OpenWebUI version, deployment topology, and the complete browser flow still
|
||||
need verification in the actual `fertigungski` host namespace.
|
||||
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
title: RollCalc Assistant
|
||||
author: RollCalc
|
||||
version: 0.1.0
|
||||
description: Deterministic RollCalc conversation API adapter
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
|
||||
def _environment_flag(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class Pipe:
|
||||
"""Expose RollCalc as an OpenWebUI model without an LLM tool loop."""
|
||||
|
||||
class Valves(BaseModel):
|
||||
ROLLCALC_API_BASE_URL: str = Field(
|
||||
default=os.getenv("ROLLCALC_API_BASE_URL", ""),
|
||||
description=(
|
||||
"RollCalc URL reachable from the OpenWebUI server/container, "
|
||||
"for example http://host.docker.internal:5000"
|
||||
),
|
||||
)
|
||||
ROLLCALC_PUBLIC_BASE_URL: str = Field(
|
||||
default=os.getenv("ROLLCALC_PUBLIC_BASE_URL", ""),
|
||||
description=(
|
||||
"RollCalc URL reachable from the user's browser; used only "
|
||||
"for authenticated PDF links"
|
||||
),
|
||||
)
|
||||
ROLLCALC_USERNAME: str = Field(
|
||||
default=os.getenv("ROLLCALC_USERNAME", ""),
|
||||
description="HTTP Basic Auth username used server-side",
|
||||
)
|
||||
ROLLCALC_PASSWORD: SecretStr = Field(
|
||||
default=SecretStr(os.getenv("ROLLCALC_PASSWORD", "")),
|
||||
description="HTTP Basic Auth password used server-side",
|
||||
)
|
||||
ROLLCALC_TIMEOUT_SECONDS: float = Field(
|
||||
default=float(os.getenv("ROLLCALC_OPENWEBUI_TIMEOUT_SECONDS", "90")),
|
||||
ge=1,
|
||||
le=600,
|
||||
description="End-to-end timeout for one RollCalc request",
|
||||
)
|
||||
ROLLCALC_DEBUG: bool = Field(
|
||||
default=_environment_flag("ROLLCALC_OPENWEBUI_DEBUG"),
|
||||
description="Log safe adapter diagnostics to the OpenWebUI console",
|
||||
)
|
||||
|
||||
type = "pipe"
|
||||
name = "RollCalc Assistant"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_factory: Callable[..., Any] | None = None,
|
||||
) -> None:
|
||||
self.valves = self.Valves()
|
||||
self._client_factory = client_factory or httpx.AsyncClient
|
||||
self._conversations: dict[tuple[str, str], str] = {}
|
||||
self._chat_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
self._lock_guard = asyncio.Lock()
|
||||
|
||||
async def pipe(
|
||||
self,
|
||||
body: dict[str, Any],
|
||||
__user__: Any = None,
|
||||
__metadata__: dict[str, Any] | None = None,
|
||||
__chat_id__: str | None = None,
|
||||
__task__: str | None = None,
|
||||
) -> str:
|
||||
"""Forward one browser message to one authoritative RollCalc session."""
|
||||
if __task__:
|
||||
return self._task_response(__task__)
|
||||
|
||||
configuration_error = self._configuration_error()
|
||||
if configuration_error:
|
||||
return configuration_error
|
||||
|
||||
message = self._user_message(body, __metadata__)
|
||||
if not message:
|
||||
return "Bitte gib eine RollCalc-Anfrage als Text ein."
|
||||
|
||||
session_key = self._session_key(
|
||||
body,
|
||||
__user__,
|
||||
__metadata__,
|
||||
__chat_id__,
|
||||
)
|
||||
if session_key is None:
|
||||
return (
|
||||
"Die RollCalc-Sitzung konnte diesem Chat nicht sicher zugeordnet "
|
||||
"werden. Bitte verwende einen gespeicherten OpenWebUI-Chat."
|
||||
)
|
||||
|
||||
chat_lock = await self._chat_lock(session_key)
|
||||
async with chat_lock:
|
||||
try:
|
||||
return await self._send_message(session_key, message)
|
||||
except httpx.TimeoutException:
|
||||
return (
|
||||
"RollCalc benötigt derzeit zu lange für eine Antwort. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return (
|
||||
"RollCalc ist derzeit nicht erreichbar. "
|
||||
"Bitte versuche es später erneut."
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return (
|
||||
"RollCalc hat eine unerwartete Antwort geliefert. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
except Exception as error:
|
||||
self._debug(f"unexpected adapter error: {error.__class__.__name__}")
|
||||
return (
|
||||
"Die RollCalc-Anfrage konnte nicht verarbeitet werden. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
|
||||
async def _send_message(
|
||||
self,
|
||||
session_key: tuple[str, str],
|
||||
message: str,
|
||||
) -> str:
|
||||
api_base = self.valves.ROLLCALC_API_BASE_URL.rstrip("/")
|
||||
timeout = self.valves.ROLLCALC_TIMEOUT_SECONDS
|
||||
auth = httpx.BasicAuth(
|
||||
self.valves.ROLLCALC_USERNAME,
|
||||
self._password(),
|
||||
)
|
||||
async with self._client_factory(timeout=timeout, auth=auth) as client:
|
||||
conversation_id = self._conversations.get(session_key)
|
||||
if conversation_id is None:
|
||||
response = await client.post(f"{api_base}/api/conversations")
|
||||
error = self._http_error(response)
|
||||
if error:
|
||||
return error
|
||||
payload = self._json_object(response)
|
||||
conversation_id = payload.get("conversation_id")
|
||||
if not isinstance(conversation_id, str) or not conversation_id:
|
||||
raise ValueError("missing conversation_id")
|
||||
self._conversations[session_key] = conversation_id
|
||||
|
||||
response = await client.post(
|
||||
f"{api_base}/api/conversations/{conversation_id}/messages",
|
||||
json={"message": message},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
self._conversations.pop(session_key, None)
|
||||
return (
|
||||
"Die RollCalc-Sitzung ist abgelaufen. Bitte starte die "
|
||||
"Berechnung mit deiner vollständigen Anfrage erneut."
|
||||
)
|
||||
error = self._http_error(response)
|
||||
if error:
|
||||
return error
|
||||
payload = self._json_object(response)
|
||||
response_message = payload.get("message")
|
||||
if not isinstance(response_message, str) or not response_message.strip():
|
||||
raise ValueError("missing user-facing message")
|
||||
return self._render_response(response_message, payload.get("pdf"))
|
||||
|
||||
async def _chat_lock(self, key: tuple[str, str]) -> asyncio.Lock:
|
||||
async with self._lock_guard:
|
||||
lock = self._chat_locks.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[key] = lock
|
||||
return lock
|
||||
|
||||
def _configuration_error(self) -> str | None:
|
||||
api_url = self.valves.ROLLCALC_API_BASE_URL.strip()
|
||||
if not self._valid_base_url(api_url):
|
||||
return (
|
||||
"RollCalc ist in OpenWebUI noch nicht vollständig konfiguriert. "
|
||||
"Bitte hinterlege eine gültige interne RollCalc-URL."
|
||||
)
|
||||
if not self.valves.ROLLCALC_USERNAME.strip() or not self._password():
|
||||
return (
|
||||
"Die RollCalc-Authentifizierung ist in OpenWebUI noch nicht "
|
||||
"vollständig konfiguriert."
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _valid_base_url(value: str) -> bool:
|
||||
parsed = urlsplit(value)
|
||||
return (
|
||||
parsed.scheme in {"http", "https"}
|
||||
and bool(parsed.netloc)
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
)
|
||||
|
||||
def _password(self) -> str:
|
||||
password = self.valves.ROLLCALC_PASSWORD
|
||||
getter = getattr(password, "get_secret_value", None)
|
||||
return getter() if callable(getter) else str(password)
|
||||
|
||||
@staticmethod
|
||||
def _json_object(response: Any) -> dict[str, Any]:
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("JSON object required")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _http_error(response: Any) -> str | None:
|
||||
if 200 <= response.status_code < 300:
|
||||
return None
|
||||
if response.status_code == 400:
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = None
|
||||
message = payload.get("message") if isinstance(payload, dict) else None
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message
|
||||
return "Die Anfrage enthält ungültige Eingaben. Bitte prüfe sie."
|
||||
if response.status_code in {401, 403}:
|
||||
return (
|
||||
"Die RollCalc-Authentifizierung ist nicht korrekt konfiguriert. "
|
||||
"Bitte informiere die Administration."
|
||||
)
|
||||
if response.status_code == 503:
|
||||
return (
|
||||
"Die lokale Spracherkennung ist derzeit nicht erreichbar. "
|
||||
"Bitte versuche es später erneut."
|
||||
)
|
||||
if response.status_code == 504:
|
||||
return (
|
||||
"Die lokale Spracherkennung benötigt derzeit zu lange. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
return (
|
||||
"RollCalc konnte die Anfrage nicht verarbeiten. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
|
||||
def _render_response(self, message: str, pdf: Any) -> str:
|
||||
lines = message.strip().splitlines()
|
||||
lines = [line for line in lines if not line.strip().startswith("PDF:")]
|
||||
if isinstance(pdf, dict) and isinstance(pdf.get("url"), str):
|
||||
report_path = pdf["url"]
|
||||
if self._valid_report_path(report_path):
|
||||
public_base = self.valves.ROLLCALC_PUBLIC_BASE_URL.strip()
|
||||
if self._valid_base_url(public_base):
|
||||
report_url = urljoin(
|
||||
f"{public_base.rstrip('/')}/",
|
||||
report_path.lstrip("/"),
|
||||
)
|
||||
lines.extend(("", f"[PDF herunterladen]({report_url})"))
|
||||
else:
|
||||
lines.extend((
|
||||
"",
|
||||
"PDF erstellt; öffentlicher Download-Link nicht "
|
||||
"konfiguriert.",
|
||||
))
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
@staticmethod
|
||||
def _valid_report_path(value: str) -> bool:
|
||||
return bool(
|
||||
re.fullmatch(
|
||||
r"/api/conversations/reports/[A-Za-z0-9_-]+\.pdf",
|
||||
value,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _user_message(
|
||||
body: dict[str, Any],
|
||||
metadata: dict[str, Any] | None,
|
||||
) -> str | None:
|
||||
if isinstance(metadata, dict):
|
||||
prompt = metadata.get("user_prompt")
|
||||
if isinstance(prompt, str) and prompt.strip():
|
||||
return prompt.strip()
|
||||
messages = body.get("messages", []) if isinstance(body, dict) else []
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for item in reversed(messages):
|
||||
if not isinstance(item, dict) or item.get("role") != "user":
|
||||
continue
|
||||
content = item.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _session_key(
|
||||
body: dict[str, Any],
|
||||
user: Any,
|
||||
metadata: dict[str, Any] | None,
|
||||
injected_chat_id: str | None,
|
||||
) -> tuple[str, str] | None:
|
||||
user_id = None
|
||||
if isinstance(user, dict):
|
||||
user_id = user.get("id") or user.get("email")
|
||||
else:
|
||||
user_id = getattr(user, "id", None) or getattr(user, "email", None)
|
||||
if not user_id and isinstance(metadata, dict):
|
||||
user_id = metadata.get("user_id")
|
||||
|
||||
chat_id = injected_chat_id
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = (
|
||||
chat_id
|
||||
or metadata.get("chat_id")
|
||||
or metadata.get("session_id")
|
||||
)
|
||||
if isinstance(body, dict):
|
||||
chat_id = chat_id or body.get("chat_id") or body.get("session_id")
|
||||
if not user_id or not chat_id:
|
||||
return None
|
||||
return str(user_id), str(chat_id)
|
||||
|
||||
@staticmethod
|
||||
def _task_response(task: str) -> str:
|
||||
normalized = task.casefold()
|
||||
if "title" in normalized:
|
||||
return "RollCalc"
|
||||
if "emoji" in normalized:
|
||||
return "🧮"
|
||||
if "tag" in normalized or "follow" in normalized:
|
||||
return "[]"
|
||||
return ""
|
||||
|
||||
def _debug(self, message: str) -> None:
|
||||
if self.valves.ROLLCALC_DEBUG:
|
||||
print(f"[RollCalc OpenWebUI] {message}")
|
||||
@@ -0,0 +1,718 @@
|
||||
"""Configured, deterministic production-machine feasibility checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from pathlib import Path
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from roll_calculation import (
|
||||
calculate_material_length_for_weight,
|
||||
calculate_material_weight,
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
)
|
||||
|
||||
|
||||
MACHINE_CONFIG_FILE = Path(__file__).parent / "config" / "machines.yaml"
|
||||
SCHEMA_VERSION = 1
|
||||
CONSTRAINT_KEYS = {
|
||||
"roll_weight_kg",
|
||||
"roll_diameter_mm",
|
||||
"allowed_core_diameters_mm",
|
||||
"product_width_m",
|
||||
"line_speed_m_per_min",
|
||||
}
|
||||
MAX_LENGTH_TIE_REL_TOLERANCE = 1e-12
|
||||
MAX_LENGTH_TIE_ABS_TOLERANCE_M = 1e-9
|
||||
|
||||
|
||||
class MachineConfigurationError(ValueError):
|
||||
"""Raised when the machine configuration is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NumericRange:
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachineConstraints:
|
||||
roll_weight_kg: NumericRange | None
|
||||
roll_diameter_mm: NumericRange | None
|
||||
allowed_core_diameters_mm: tuple[float, ...] | None
|
||||
product_width_m: NumericRange | None
|
||||
line_speed_m_per_min: NumericRange | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Machine:
|
||||
id: str
|
||||
name: str
|
||||
aliases: tuple[str, ...]
|
||||
location: str
|
||||
constraints: MachineConstraints
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachineFeasibilityRequest:
|
||||
machine: str
|
||||
roll_weight_kg: float | None = None
|
||||
average_diameter_mm: float | None = None
|
||||
maximum_diameter_mm: float | None = None
|
||||
core_diameter_mm: float | None = None
|
||||
product_width_m: float | None = None
|
||||
line_speed_m_per_min: float | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: Any) -> "MachineFeasibilityRequest":
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request must be a JSON object")
|
||||
allowed = set(cls.__dataclass_fields__)
|
||||
unknown = sorted(set(payload) - allowed)
|
||||
if unknown:
|
||||
raise ValueError("unknown request fields: " + ", ".join(unknown))
|
||||
machine = payload.get("machine")
|
||||
if not isinstance(machine, str) or not machine.strip():
|
||||
raise ValueError("machine must be non-empty text")
|
||||
values: dict[str, Any] = {"machine": machine.strip()}
|
||||
for field in allowed - {"machine"}:
|
||||
value = payload.get(field)
|
||||
if value is None:
|
||||
values[field] = None
|
||||
continue
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{field} must be a number")
|
||||
number = float(value)
|
||||
if not math.isfinite(number) or number <= 0:
|
||||
raise ValueError(f"{field} must be greater than zero")
|
||||
values[field] = number
|
||||
average = values["average_diameter_mm"]
|
||||
maximum = values["maximum_diameter_mm"]
|
||||
if average is not None and maximum is not None and maximum < average:
|
||||
raise ValueError(
|
||||
"maximum_diameter_mm must be greater than or equal to "
|
||||
"average_diameter_mm"
|
||||
)
|
||||
return cls(**values)
|
||||
|
||||
|
||||
def _normalize_lookup(value: str) -> str:
|
||||
return " ".join(unicodedata.normalize("NFKC", value).casefold().split())
|
||||
|
||||
|
||||
def _number(value: Any, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise MachineConfigurationError(f"{field} must be a number")
|
||||
value = float(value)
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise MachineConfigurationError(f"{field} must be greater than zero")
|
||||
return value
|
||||
|
||||
|
||||
def _range(value: Any, field: str) -> NumericRange | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise MachineConfigurationError(f"{field} must be an object or null")
|
||||
unknown = sorted(set(value) - {"minimum", "maximum"})
|
||||
if unknown:
|
||||
raise MachineConfigurationError(f"{field} has unknown keys: {', '.join(unknown)}")
|
||||
if not value:
|
||||
raise MachineConfigurationError(f"{field} must contain a limit")
|
||||
minimum = _number(value["minimum"], f"{field}.minimum") if "minimum" in value else None
|
||||
maximum = _number(value["maximum"], f"{field}.maximum") if "maximum" in value else None
|
||||
if minimum is None and maximum is None:
|
||||
raise MachineConfigurationError(f"{field} must contain a limit")
|
||||
if minimum is not None and maximum is not None and minimum > maximum:
|
||||
raise MachineConfigurationError(f"{field}.minimum must not exceed maximum")
|
||||
return NumericRange(minimum=minimum, maximum=maximum)
|
||||
|
||||
|
||||
def _core_values(value: Any) -> tuple[float, ...] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list) or not value:
|
||||
raise MachineConfigurationError(
|
||||
"allowed_core_diameters_mm must be a non-empty list or null"
|
||||
)
|
||||
values = tuple(_number(item, "allowed_core_diameters_mm item") for item in value)
|
||||
if len(set(values)) != len(values):
|
||||
raise MachineConfigurationError("allowed_core_diameters_mm must not repeat values")
|
||||
return values
|
||||
|
||||
|
||||
class MachineRepository:
|
||||
"""Load, validate, and resolve configured production machines."""
|
||||
|
||||
def __init__(self, machines: tuple[Machine, ...]):
|
||||
self.machines = machines
|
||||
self._by_lookup: dict[str, Machine] = {}
|
||||
for machine in machines:
|
||||
identifiers = (machine.id, machine.name, *machine.aliases)
|
||||
local: set[str] = set()
|
||||
for position, value in enumerate(identifiers):
|
||||
normalized = _normalize_lookup(value)
|
||||
# A machine such as K1 legitimately has identical stable ID
|
||||
# and display name. Any alias collision remains invalid.
|
||||
if normalized in local and position > 1:
|
||||
raise MachineConfigurationError(
|
||||
f"duplicate machine lookup identifier: {value}"
|
||||
)
|
||||
local.add(normalized)
|
||||
existing = self._by_lookup.get(normalized)
|
||||
if existing is not None and existing is not machine:
|
||||
raise MachineConfigurationError(
|
||||
f"duplicate machine lookup identifier: {value}"
|
||||
)
|
||||
self._by_lookup[normalized] = machine
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path = MACHINE_CONFIG_FILE) -> "MachineRepository":
|
||||
try:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
data = yaml.safe_load(handle)
|
||||
except OSError as error:
|
||||
raise MachineConfigurationError(
|
||||
f"unable to load machine configuration: {error}"
|
||||
) from error
|
||||
except yaml.YAMLError as error:
|
||||
raise MachineConfigurationError(
|
||||
f"invalid machine configuration YAML: {error}"
|
||||
) from error
|
||||
return cls(_parse_config(data))
|
||||
|
||||
def resolve(self, machine: str) -> dict[str, Any]:
|
||||
if not isinstance(machine, str) or not machine.strip():
|
||||
return {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{"field": "machine", "message": "machine must be non-empty text"}],
|
||||
}
|
||||
resolved = self._by_lookup.get(_normalize_lookup(machine))
|
||||
if resolved is None:
|
||||
return {"status": "machine_not_found", "machine": machine.strip()}
|
||||
matched_by = "id" if _normalize_lookup(machine) == _normalize_lookup(resolved.id) else "name"
|
||||
if matched_by == "name" and _normalize_lookup(machine) != _normalize_lookup(resolved.name):
|
||||
matched_by = "alias"
|
||||
return {
|
||||
"status": "resolved",
|
||||
"machine": _public_machine(resolved),
|
||||
"resolved_machine": resolved,
|
||||
"matched_by": matched_by,
|
||||
"matched_value": machine.strip(),
|
||||
}
|
||||
|
||||
|
||||
def _parse_config(data: Any) -> tuple[Machine, ...]:
|
||||
if not isinstance(data, dict):
|
||||
raise MachineConfigurationError("machine configuration must be an object")
|
||||
unknown = sorted(set(data) - {"schema_version", "machines"})
|
||||
if unknown:
|
||||
raise MachineConfigurationError("unknown configuration keys: " + ", ".join(unknown))
|
||||
if (
|
||||
isinstance(data.get("schema_version"), bool)
|
||||
or data.get("schema_version") != SCHEMA_VERSION
|
||||
):
|
||||
raise MachineConfigurationError(f"unsupported schema_version: {data.get('schema_version')!r}")
|
||||
rows = data.get("machines")
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise MachineConfigurationError("machines must be a non-empty list")
|
||||
machines: list[Machine] = []
|
||||
ids: set[str] = set()
|
||||
for index, row in enumerate(rows):
|
||||
prefix = f"machines[{index}]"
|
||||
if not isinstance(row, dict):
|
||||
raise MachineConfigurationError(f"{prefix} must be an object")
|
||||
unknown = sorted(set(row) - {"id", "name", "aliases", "location", "constraints"})
|
||||
if unknown:
|
||||
raise MachineConfigurationError(f"{prefix} has unknown keys: {', '.join(unknown)}")
|
||||
if set(row) != {"id", "name", "aliases", "location", "constraints"}:
|
||||
raise MachineConfigurationError(f"{prefix} must contain id, name, aliases, location, and constraints")
|
||||
text = {}
|
||||
for key in ("id", "name", "location"):
|
||||
value = row[key]
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise MachineConfigurationError(f"{prefix}.{key} must be non-empty text")
|
||||
text[key] = value.strip()
|
||||
if text["id"] in ids:
|
||||
raise MachineConfigurationError(f"duplicate machine id: {text['id']}")
|
||||
ids.add(text["id"])
|
||||
aliases = row["aliases"]
|
||||
if not isinstance(aliases, list) or not all(isinstance(item, str) and item.strip() for item in aliases):
|
||||
raise MachineConfigurationError(f"{prefix}.aliases must be a list of non-empty text")
|
||||
constraints = row["constraints"]
|
||||
if not isinstance(constraints, dict) or set(constraints) != CONSTRAINT_KEYS:
|
||||
raise MachineConfigurationError(f"{prefix}.constraints must contain exactly the supported constraint keys")
|
||||
machines.append(Machine(
|
||||
id=text["id"], name=text["name"], aliases=tuple(item.strip() for item in aliases),
|
||||
location=text["location"], constraints=MachineConstraints(
|
||||
roll_weight_kg=_range(constraints["roll_weight_kg"], f"{prefix}.constraints.roll_weight_kg"),
|
||||
roll_diameter_mm=_range(constraints["roll_diameter_mm"], f"{prefix}.constraints.roll_diameter_mm"),
|
||||
allowed_core_diameters_mm=_core_values(constraints["allowed_core_diameters_mm"]),
|
||||
product_width_m=_range(constraints["product_width_m"], f"{prefix}.constraints.product_width_m"),
|
||||
line_speed_m_per_min=_range(constraints["line_speed_m_per_min"], f"{prefix}.constraints.line_speed_m_per_min"),
|
||||
),
|
||||
))
|
||||
return tuple(machines)
|
||||
|
||||
|
||||
def _range_public(value: NumericRange | None, unit: str) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return {"minimum": value.minimum, "maximum": value.maximum, "unit": unit}
|
||||
|
||||
|
||||
def _public_machine(machine: Machine) -> dict[str, Any]:
|
||||
constraints = machine.constraints
|
||||
return {
|
||||
"id": machine.id, "name": machine.name, "aliases": list(machine.aliases),
|
||||
"location": machine.location,
|
||||
"constraints": {
|
||||
"roll_weight_kg": _range_public(constraints.roll_weight_kg, "kg"),
|
||||
"roll_diameter_mm": _range_public(constraints.roll_diameter_mm, "mm"),
|
||||
"allowed_core_diameters_mm": list(constraints.allowed_core_diameters_mm) if constraints.allowed_core_diameters_mm is not None else None,
|
||||
"product_width_m": _range_public(constraints.product_width_m, "m"),
|
||||
"line_speed_m_per_min": _range_public(constraints.line_speed_m_per_min, "m/min"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_machine(machine: str | None, *, repository: MachineRepository | None = None) -> dict[str, Any]:
|
||||
"""Resolve one configured machine without applying production rules."""
|
||||
result = (repository or MachineRepository.load()).resolve(machine) # type: ignore[arg-type]
|
||||
return {key: value for key, value in result.items() if key != "resolved_machine"}
|
||||
|
||||
|
||||
def _range_check(name: str, actual: float | None, limits: NumericRange | None, unit: str) -> dict[str, Any]:
|
||||
if limits is None:
|
||||
return {"constraint": name, "state": "not_configured", "unit": unit}
|
||||
if actual is None:
|
||||
return {"constraint": name, "state": "not_evaluated", "reason": "missing_actual_value", "limits": _range_public(limits, unit), "unit": unit}
|
||||
failures = []
|
||||
margins: dict[str, dict[str, float | str]] = {}
|
||||
if limits.minimum is not None:
|
||||
margins["margin_to_minimum"] = {"value": actual - limits.minimum, "unit": unit}
|
||||
if actual < limits.minimum:
|
||||
failures.append("below_minimum")
|
||||
if limits.maximum is not None:
|
||||
margins["margin_to_maximum"] = {"value": limits.maximum - actual, "unit": unit}
|
||||
if actual > limits.maximum:
|
||||
failures.append("above_maximum")
|
||||
return {"constraint": name, "state": "failed" if failures else "passed", "actual": {"value": actual, "unit": unit}, "limits": _range_public(limits, unit), "violations": failures, "margins": margins}
|
||||
|
||||
|
||||
def _diameter_check(average: float | None, maximum: float | None, limits: NumericRange | None) -> dict[str, Any]:
|
||||
if limits is None:
|
||||
return {"constraint": "roll_diameter_mm", "state": "not_configured", "unit": "mm"}
|
||||
if average is None or maximum is None:
|
||||
return {"constraint": "roll_diameter_mm", "state": "not_evaluated", "reason": "missing_actual_value", "limits": _range_public(limits, "mm"), "unit": "mm"}
|
||||
violations = []
|
||||
if limits.minimum is not None and average < limits.minimum:
|
||||
violations.append("average_below_minimum")
|
||||
if limits.maximum is not None and average > limits.maximum:
|
||||
violations.append("average_above_maximum")
|
||||
warning = limits.maximum is not None and average <= limits.maximum and maximum > limits.maximum
|
||||
state = "failed" if violations else "warning" if warning else "passed"
|
||||
margins: dict[str, dict[str, float | str]] = {}
|
||||
if limits.minimum is not None:
|
||||
margins["average_margin_to_minimum"] = {"value": average - limits.minimum, "unit": "mm"}
|
||||
if limits.maximum is not None:
|
||||
margins["average_margin_to_maximum"] = {"value": limits.maximum - average, "unit": "mm"}
|
||||
margins["maximum_margin_to_maximum"] = {"value": limits.maximum - maximum, "unit": "mm"}
|
||||
return {
|
||||
"constraint": "roll_diameter_mm", "state": state,
|
||||
"actual": {"average_diameter_mm": average, "maximum_diameter_mm": maximum, "unit": "mm"},
|
||||
"limits": _range_public(limits, "mm"), "violations": violations,
|
||||
"warnings": ["maximum_diameter_above_maximum"] if warning else [], "margins": margins,
|
||||
"minimum_evaluation": "average_diameter_mm",
|
||||
}
|
||||
|
||||
|
||||
def _core_check(actual: float | None, allowed: tuple[float, ...] | None) -> dict[str, Any]:
|
||||
if allowed is None:
|
||||
return {"constraint": "allowed_core_diameters_mm", "state": "not_configured", "unit": "mm"}
|
||||
if actual is None:
|
||||
return {"constraint": "allowed_core_diameters_mm", "state": "not_evaluated", "reason": "missing_actual_value", "allowed_values": list(allowed), "unit": "mm"}
|
||||
return {"constraint": "allowed_core_diameters_mm", "state": "passed" if actual in allowed else "failed", "actual": {"value": actual, "unit": "mm"}, "allowed_values": list(allowed), "unit": "mm", "violations": [] if actual in allowed else ["not_allowed"]}
|
||||
|
||||
|
||||
def check_production_feasibility(
|
||||
request: MachineFeasibilityRequest | dict[str, Any], *, repository: MachineRepository | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Evaluate all configured V1 roll constraints without short-circuiting.
|
||||
|
||||
Diameter minima use RollCalc's nominal/average diameter. A maximum-diameter
|
||||
variation exceedance is a warning only when the nominal diameter still fits.
|
||||
Roll weight is the caller-supplied material-only weight in V1.
|
||||
"""
|
||||
if not isinstance(request, MachineFeasibilityRequest):
|
||||
try:
|
||||
request = MachineFeasibilityRequest.from_dict(request)
|
||||
except ValueError as error:
|
||||
return {"status": "invalid_parameter", "invalid": [{"field": "request", "message": str(error)}]}
|
||||
resolved = (repository or MachineRepository.load()).resolve(request.machine)
|
||||
if resolved["status"] != "resolved":
|
||||
return {key: value for key, value in resolved.items() if key != "resolved_machine"}
|
||||
machine: Machine = resolved["resolved_machine"]
|
||||
constraints = machine.constraints
|
||||
checks = [
|
||||
_range_check("roll_weight_kg", request.roll_weight_kg, constraints.roll_weight_kg, "kg"),
|
||||
_diameter_check(request.average_diameter_mm, request.maximum_diameter_mm, constraints.roll_diameter_mm),
|
||||
_core_check(request.core_diameter_mm, constraints.allowed_core_diameters_mm),
|
||||
_range_check("product_width_m", request.product_width_m, constraints.product_width_m, "m"),
|
||||
{"constraint": "line_speed_m_per_min", "state": "not_evaluated", "reason": "outside_initial_roll_feasibility_scope", "limits": _range_public(constraints.line_speed_m_per_min, "m/min"), "unit": "m/min"},
|
||||
]
|
||||
required = {}
|
||||
if constraints.roll_weight_kg is not None:
|
||||
required["roll_weight_kg"] = request.roll_weight_kg
|
||||
if constraints.roll_diameter_mm is not None:
|
||||
required["average_diameter_mm"] = request.average_diameter_mm
|
||||
required["maximum_diameter_mm"] = request.maximum_diameter_mm
|
||||
if constraints.allowed_core_diameters_mm is not None:
|
||||
required["core_diameter_mm"] = request.core_diameter_mm
|
||||
if constraints.product_width_m is not None:
|
||||
required["product_width_m"] = request.product_width_m
|
||||
missing = [field for field, value in required.items() if value is None]
|
||||
failed = [check["constraint"] for check in checks if check["state"] == "failed"]
|
||||
warnings = [check["constraint"] for check in checks if check["state"] == "warning"]
|
||||
if missing:
|
||||
feasibility = "needs_clarification"
|
||||
status = "needs_clarification"
|
||||
feasible: bool | None = None
|
||||
elif failed:
|
||||
feasibility = "not_feasible"
|
||||
status = "success"
|
||||
feasible = False
|
||||
elif warnings:
|
||||
feasibility = "feasible_with_warnings"
|
||||
status = "success"
|
||||
feasible = True
|
||||
else:
|
||||
feasibility = "feasible"
|
||||
status = "success"
|
||||
feasible = True
|
||||
return {
|
||||
"status": status,
|
||||
"machine": resolved["machine"],
|
||||
"resolution": {"matched_by": resolved["matched_by"], "matched_value": resolved["matched_value"]},
|
||||
"feasibility": feasibility,
|
||||
"feasible": feasible,
|
||||
"weight_scope": "material_only",
|
||||
"checks": checks,
|
||||
"passed_constraints": [check["constraint"] for check in checks if check["state"] == "passed"],
|
||||
"warning_constraints": warnings,
|
||||
"failed_constraints": failed,
|
||||
"missing_required_inputs": missing,
|
||||
}
|
||||
|
||||
|
||||
def _machine_max_input_error(field: str, value: Any, *, allow_zero: bool = False) -> dict[str, str] | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return {"field": field, "message": "must be a number"}
|
||||
if not math.isfinite(float(value)) or (float(value) < 0 if allow_zero else float(value) <= 0):
|
||||
return {
|
||||
"field": field,
|
||||
"message": "must be zero or greater" if allow_zero else "must be greater than zero",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _maximum_limit_state(value: NumericRange | None) -> str:
|
||||
if value is None or value.maximum is None:
|
||||
return "not_configured"
|
||||
return "configured"
|
||||
|
||||
|
||||
def _governing_maximum(
|
||||
candidates: tuple[tuple[str, float], ...],
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Choose all co-governing limits with a fixed, calculation-only tolerance."""
|
||||
maximum = min(value for _, value in candidates)
|
||||
governing = [
|
||||
name
|
||||
for name, value in candidates
|
||||
if math.isclose(
|
||||
value,
|
||||
maximum,
|
||||
rel_tol=MAX_LENGTH_TIE_REL_TOLERANCE,
|
||||
abs_tol=MAX_LENGTH_TIE_ABS_TOLERANCE_M,
|
||||
)
|
||||
]
|
||||
return maximum, governing
|
||||
|
||||
|
||||
def _verification_boundary_value(value: float | None, limits: NumericRange | None) -> float | None:
|
||||
"""Normalize only inverse-calculation floating-point noise at a hard bound."""
|
||||
if value is None or limits is None:
|
||||
return value
|
||||
if (
|
||||
limits.minimum is not None
|
||||
and value < limits.minimum
|
||||
and math.isclose(
|
||||
value,
|
||||
limits.minimum,
|
||||
rel_tol=MAX_LENGTH_TIE_REL_TOLERANCE,
|
||||
abs_tol=MAX_LENGTH_TIE_ABS_TOLERANCE_M,
|
||||
)
|
||||
):
|
||||
return limits.minimum
|
||||
if (
|
||||
limits.maximum is not None
|
||||
and value > limits.maximum
|
||||
and math.isclose(
|
||||
value,
|
||||
limits.maximum,
|
||||
rel_tol=MAX_LENGTH_TIE_REL_TOLERANCE,
|
||||
abs_tol=MAX_LENGTH_TIE_ABS_TOLERANCE_M,
|
||||
)
|
||||
):
|
||||
return limits.maximum
|
||||
return value
|
||||
|
||||
|
||||
def calculate_machine_max_product_length(
|
||||
*,
|
||||
machine: str,
|
||||
core_diameter_mm: Any,
|
||||
thickness_mm: Any,
|
||||
thickness_stddev_mm: Any = None,
|
||||
product_width_m: Any = None,
|
||||
area_weight_g_m2: Any = None,
|
||||
repository: MachineRepository | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate deterministic machine-limited product lengths from shared domains.
|
||||
|
||||
Diameter limits are delegated to ``calculate_product_length`` and material
|
||||
weight limits to ``calculate_material_length_for_weight``. This function
|
||||
only composes configured machine constraints; it contains no roll formula.
|
||||
"""
|
||||
resolved = (repository or MachineRepository.load()).resolve(machine)
|
||||
if resolved["status"] != "resolved":
|
||||
return {key: value for key, value in resolved.items() if key != "resolved_machine"}
|
||||
|
||||
invalid = []
|
||||
for field, value in (("core_diameter_mm", core_diameter_mm), ("thickness_mm", thickness_mm)):
|
||||
error = _machine_max_input_error(field, value)
|
||||
if error:
|
||||
invalid.append(error)
|
||||
if thickness_stddev_mm is None:
|
||||
thickness_stddev = 0.0
|
||||
else:
|
||||
error = _machine_max_input_error(
|
||||
"thickness_stddev_mm", thickness_stddev_mm, allow_zero=True
|
||||
)
|
||||
if error:
|
||||
invalid.append(error)
|
||||
thickness_stddev = 0.0
|
||||
else:
|
||||
thickness_stddev = float(thickness_stddev_mm)
|
||||
for field, value in (("product_width_m", product_width_m), ("area_weight_g_m2", area_weight_g_m2)):
|
||||
if value is not None:
|
||||
error = _machine_max_input_error(field, value)
|
||||
if error:
|
||||
invalid.append(error)
|
||||
if invalid:
|
||||
return {"status": "invalid_parameter", "invalid": invalid}
|
||||
|
||||
core = float(core_diameter_mm)
|
||||
thickness = float(thickness_mm)
|
||||
width = float(product_width_m) if product_width_m is not None else None
|
||||
area_weight = float(area_weight_g_m2) if area_weight_g_m2 is not None else None
|
||||
machine_model: Machine = resolved["resolved_machine"]
|
||||
constraints = machine_model.constraints
|
||||
compatibility_checks = [
|
||||
_core_check(core, constraints.allowed_core_diameters_mm),
|
||||
_range_check("product_width_m", width, constraints.product_width_m, "m"),
|
||||
]
|
||||
base = {
|
||||
"machine": resolved["machine"],
|
||||
"resolution": {
|
||||
"matched_by": resolved["matched_by"],
|
||||
"matched_value": resolved["matched_value"],
|
||||
},
|
||||
"weight_scope": "material_only",
|
||||
"inputs": {
|
||||
"core_diameter_mm": core,
|
||||
"thickness_mm": thickness,
|
||||
"thickness_stddev_mm": thickness_stddev,
|
||||
"product_width_m": width,
|
||||
"area_weight_g_m2": area_weight,
|
||||
},
|
||||
"compatibility_checks": compatibility_checks,
|
||||
"units": {"length_m": "m", "diameter_mm": "mm", "weight_kg": "kg"},
|
||||
}
|
||||
failed_compatibility = [
|
||||
check["constraint"] for check in compatibility_checks if check["state"] == "failed"
|
||||
]
|
||||
if failed_compatibility:
|
||||
return {
|
||||
"status": "incompatible",
|
||||
**base,
|
||||
"failed_compatibility_constraints": failed_compatibility,
|
||||
"production_maximums": None,
|
||||
"missing_required_inputs": [],
|
||||
}
|
||||
|
||||
missing = []
|
||||
if constraints.product_width_m is not None and width is None:
|
||||
missing.append("product_width_m")
|
||||
if constraints.roll_weight_kg is not None:
|
||||
if width is None and "product_width_m" not in missing:
|
||||
missing.append("product_width_m")
|
||||
if area_weight is None:
|
||||
missing.append("area_weight_g_m2")
|
||||
|
||||
diameter_limits: dict[str, float | None] = {
|
||||
"nominal_diameter_length_m": None,
|
||||
"conservative_diameter_length_m": None,
|
||||
}
|
||||
if constraints.roll_diameter_mm is not None and constraints.roll_diameter_mm.maximum is not None:
|
||||
diameter_result = calculate_product_length(
|
||||
target_roll_diameter_mm=constraints.roll_diameter_mm.maximum,
|
||||
core_diameter_mm=core,
|
||||
thickness_mm=thickness,
|
||||
thickness_stddev_mm=thickness_stddev,
|
||||
)
|
||||
if diameter_result["status"] != "success":
|
||||
return {**base, **diameter_result, "production_maximums": None}
|
||||
calculation = diameter_result["calculation"]
|
||||
diameter_limits = {
|
||||
"nominal_diameter_length_m": calculation["average_product_length_m"],
|
||||
"conservative_diameter_length_m": calculation["minimum_product_length_m"],
|
||||
}
|
||||
|
||||
weight_limit: float | None = None
|
||||
if constraints.roll_weight_kg is not None and constraints.roll_weight_kg.maximum is not None and not missing:
|
||||
weight_result = calculate_material_length_for_weight(
|
||||
target_material_weight_kg=constraints.roll_weight_kg.maximum,
|
||||
width_m=width,
|
||||
area_weight_g_m2=area_weight,
|
||||
)
|
||||
if weight_result["status"] != "success":
|
||||
return {**base, **weight_result, "production_maximums": None}
|
||||
weight_limit = weight_result["material_length_m"]
|
||||
|
||||
limits = {
|
||||
**diameter_limits,
|
||||
"material_weight_length_m": weight_limit,
|
||||
"roll_diameter_maximum_state": _maximum_limit_state(constraints.roll_diameter_mm),
|
||||
"material_weight_maximum_state": _maximum_limit_state(constraints.roll_weight_kg),
|
||||
}
|
||||
if missing:
|
||||
partial_limits = {
|
||||
key: value for key, value in diameter_limits.items() if value is not None
|
||||
}
|
||||
return {
|
||||
"status": "needs_clarification",
|
||||
**base,
|
||||
"limits": limits,
|
||||
"partial_limits": partial_limits or None,
|
||||
"production_maximums": None,
|
||||
"missing_required_inputs": missing,
|
||||
}
|
||||
|
||||
nominal_candidates: list[tuple[str, float]] = []
|
||||
conservative_candidates: list[tuple[str, float]] = []
|
||||
if diameter_limits["nominal_diameter_length_m"] is not None:
|
||||
nominal_candidates.append(("roll_diameter_nominal", diameter_limits["nominal_diameter_length_m"]))
|
||||
conservative_candidates.append(("roll_diameter_conservative", diameter_limits["conservative_diameter_length_m"]))
|
||||
if weight_limit is not None:
|
||||
nominal_candidates.append(("material_weight", weight_limit))
|
||||
conservative_candidates.append(("material_weight", weight_limit))
|
||||
if not nominal_candidates:
|
||||
return {
|
||||
"status": "no_maximum_length_constraint",
|
||||
**base,
|
||||
"limits": limits,
|
||||
"production_maximums": None,
|
||||
"missing_required_inputs": [],
|
||||
}
|
||||
|
||||
nominal_length, nominal_governing = _governing_maximum(tuple(nominal_candidates))
|
||||
conservative_length, conservative_governing = _governing_maximum(
|
||||
tuple(conservative_candidates)
|
||||
)
|
||||
production_maximums = {
|
||||
"nominal_length_m": nominal_length,
|
||||
"nominal_governing_constraints": nominal_governing,
|
||||
"conservative_length_m": conservative_length,
|
||||
"conservative_governing_constraints": conservative_governing,
|
||||
}
|
||||
|
||||
material_weights = None
|
||||
if width is not None and area_weight is not None:
|
||||
nominal_weight = calculate_material_weight(
|
||||
roll_length_m=nominal_length, width_m=width, area_weight_g_m2=area_weight
|
||||
)
|
||||
conservative_weight = calculate_material_weight(
|
||||
roll_length_m=conservative_length, width_m=width, area_weight_g_m2=area_weight
|
||||
)
|
||||
material_weights = {
|
||||
"at_nominal_production_maximum": nominal_weight["material_weight_kg"],
|
||||
"at_conservative_production_maximum": conservative_weight["material_weight_kg"],
|
||||
}
|
||||
|
||||
final_feasibility = {}
|
||||
for label, length in (("nominal", nominal_length), ("conservative", conservative_length)):
|
||||
roll_result = calculate_roll({
|
||||
"roll_length_m": length,
|
||||
"core_diameter_mm": core,
|
||||
"thickness_mm": thickness,
|
||||
"thickness_stddev_mm": thickness_stddev,
|
||||
"width_m": width,
|
||||
"area_weight_g_m2": area_weight,
|
||||
"include_roll_weight": constraints.roll_weight_kg is not None,
|
||||
})
|
||||
if roll_result["status"] != "success":
|
||||
return {
|
||||
"status": "incompatible",
|
||||
**base,
|
||||
"limits": limits,
|
||||
"production_maximums": None,
|
||||
"final_feasibility": {label: roll_result},
|
||||
"missing_required_inputs": [],
|
||||
}
|
||||
calculated = roll_result["calculation"]
|
||||
feasibility = check_production_feasibility({
|
||||
"machine": machine_model.id,
|
||||
"roll_weight_kg": _verification_boundary_value(
|
||||
calculated["roll_weight_kg"], constraints.roll_weight_kg
|
||||
),
|
||||
"average_diameter_mm": _verification_boundary_value(
|
||||
calculated["average_diameter_mm"], constraints.roll_diameter_mm
|
||||
),
|
||||
"maximum_diameter_mm": _verification_boundary_value(
|
||||
calculated["maximum_diameter_mm"], constraints.roll_diameter_mm
|
||||
),
|
||||
"core_diameter_mm": core,
|
||||
"product_width_m": width,
|
||||
}, repository=repository)
|
||||
final_feasibility[label] = feasibility
|
||||
if feasibility.get("feasible") is not True:
|
||||
return {
|
||||
"status": "incompatible",
|
||||
**base,
|
||||
"limits": limits,
|
||||
"production_maximums": None,
|
||||
"final_feasibility": final_feasibility,
|
||||
"missing_required_inputs": [],
|
||||
}
|
||||
return {
|
||||
"status": "success",
|
||||
**base,
|
||||
"limits": limits,
|
||||
"production_maximums": production_maximums,
|
||||
"material_weights_kg": material_weights,
|
||||
"final_feasibility": final_feasibility,
|
||||
"warnings": {
|
||||
label: value["warning_constraints"]
|
||||
for label, value in final_feasibility.items()
|
||||
if value["warning_constraints"]
|
||||
},
|
||||
"missing_required_inputs": [],
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
"""RollCalc's minimal stdio MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rollcalc_mcp_tools import (
|
||||
analyze_transport_capacity_result,
|
||||
calculate_machine_max_product_length_result,
|
||||
calculate_material_weight_result,
|
||||
calculate_product_length_result,
|
||||
calculate_roll_diameter_result,
|
||||
get_article_result,
|
||||
get_machine_result,
|
||||
check_production_feasibility_result,
|
||||
search_articles_result,
|
||||
)
|
||||
from transport_calculation import TransportPresetKey
|
||||
|
||||
|
||||
def create_server() -> Any:
|
||||
"""Create the stdio-only MCP server after the SDK is installed."""
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
server = FastMCP("RollCalc")
|
||||
|
||||
@server.tool()
|
||||
def get_article(
|
||||
article_number: str | None = None,
|
||||
article_name_hint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve RollCalc article master data. article_number is text (including leading zeroes); optional article_name_hint preserves existing conflict and ambiguity checks."""
|
||||
return get_article_result(article_number, article_name_hint)
|
||||
|
||||
@server.tool()
|
||||
def search_articles(query: str) -> dict[str, Any]:
|
||||
"""Use search_articles when the user identifies a RollCalc product by name, family, designation, or descriptive article text instead of an exact article number. Do not guess or invent an article number: results are deterministic retrieval candidates from the RollCalc article master data, not product recommendations. For an exact article number, use get_article -> calculate_roll_diameter -> optionally analyze_transport_capacity. For a product name, use search_articles first. Only when total_matches is exactly 1 may you use that candidate's exact article_number and continue through get_article and the requested workflow. When multiple plausible candidates are returned, do NOT select the first or highest-ranked candidate, do not treat ranking as permission to choose, and do not calculate yet: present relevant candidates with their article numbers, distinguishing names, and production_site when supplied; ask the user which article number is intended, then stop until they select it. Ranking/order expresses retrieval relevance only and never authorizes automatic selection. If no candidate is returned, say that no matching RollCalc article was found, ask for a more specific designation or article number, and do not invent an article. production_site=Malaysia is descriptive metadata only: it does not imply Bentofix, Bento 2, or any production machine."""
|
||||
return search_articles_result(query)
|
||||
|
||||
@server.tool()
|
||||
def get_machine(machine: str) -> dict[str, Any]:
|
||||
"""Resolve a configured production machine by stable ID, exact name, or configured alias. Machine limits are deterministic configured domain data; aliases and locations are lookup/metadata only and do not imply capabilities."""
|
||||
return get_machine_result(machine)
|
||||
|
||||
@server.tool()
|
||||
def check_production_feasibility(
|
||||
machine: str,
|
||||
roll_weight_kg: float | None = None,
|
||||
average_diameter_mm: float | None = None,
|
||||
maximum_diameter_mm: float | None = None,
|
||||
core_diameter_mm: float | None = None,
|
||||
product_width_m: float | None = None,
|
||||
line_speed_m_per_min: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Deterministically check a material-only calculated roll against one configured machine. The tool, not the LLM, decides every independent limit; never override failed constraints. Report returned warnings, especially maximum-diameter variation warnings. Missing configured V1 roll inputs require clarification rather than assumptions. average_diameter_mm and maximum_diameter_mm are the existing RollCalc calculation outputs; line speed is retained but outside the initial roll-feasibility scope."""
|
||||
return check_production_feasibility_result(
|
||||
machine=machine,
|
||||
roll_weight_kg=roll_weight_kg,
|
||||
average_diameter_mm=average_diameter_mm,
|
||||
maximum_diameter_mm=maximum_diameter_mm,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
product_width_m=product_width_m,
|
||||
line_speed_m_per_min=line_speed_m_per_min,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def calculate_machine_max_product_length(
|
||||
machine: str,
|
||||
core_diameter_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. 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,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def calculate_material_weight(
|
||||
roll_length_m: float,
|
||||
width_m: float,
|
||||
area_weight_g_m2: float,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate deterministic material-only roll weight from roll_length_m (m), width_m (m), and area_weight_g_m2 (g/m²). Use this when those inputs are known but core diameter is unknown or not needed. It does not require, accept, infer, or default a core diameter; never invent a standard core. The result excludes any core weight and cannot provide roll diameter or a core-inclusive total weight. For roll diameter, use calculate_roll_diameter only with a known positive core diameter."""
|
||||
return calculate_material_weight_result(
|
||||
roll_length_m=roll_length_m,
|
||||
width_m=width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def calculate_product_length(
|
||||
target_roll_diameter_mm: float,
|
||||
core_diameter_mm: float,
|
||||
thickness_mm: float,
|
||||
thickness_stddev_mm: float | None = None,
|
||||
width_m: float | None = None,
|
||||
area_weight_g_m2: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate required product/roll length for a target outer roll diameter. target_roll_diameter_mm, core_diameter_mm, and thickness_mm are required and expressed in mm. Use exact article master-data values obtained through get_article when available; do not infer a missing core diameter, thickness, width, area weight, or article. thickness_stddev_mm returns deterministic minimum, average, and maximum length ranges. When both width_m (m) and area_weight_g_m2 (g/m²) are supplied, the result also includes corresponding material-weight ranges. This tool calculates length from target diameter; use calculate_roll_diameter for the reverse direction."""
|
||||
return calculate_product_length_result(
|
||||
target_roll_diameter_mm=target_roll_diameter_mm,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
thickness_mm=thickness_mm,
|
||||
thickness_stddev_mm=thickness_stddev_mm,
|
||||
width_m=width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def calculate_roll_diameter(
|
||||
roll_length_m: float | None = None,
|
||||
core_diameter_mm: float | None = None,
|
||||
thickness_mm: float | None = None,
|
||||
thickness_stddev_mm: float | None = None,
|
||||
article_number: str | None = None,
|
||||
article_name_hint: str | None = None,
|
||||
core_type: str | None = None,
|
||||
width_m: float | None = None,
|
||||
area_weight_g_m2: float | None = None,
|
||||
category: str | None = None,
|
||||
production_site: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate roll diameter and weight for one roll. roll_length_m is the material length on one roll in m; core_diameter_mm, thickness_mm, and thickness_stddev_mm are mm; width_m is m; and area_weight_g_m2 is g/m². Roll diameter requires a known positive core diameter: do not infer or default one. If only material weight is needed and the core is unknown, call calculate_material_weight instead; it returns material-only weight, not a diameter or core-inclusive total. On success, transport_roll_inputs contains the browser-equivalent transport values; pass that bundle's fields unchanged to analyze_transport_capacity."""
|
||||
return calculate_roll_diameter_result(
|
||||
article_number=article_number,
|
||||
article_name_hint=article_name_hint,
|
||||
roll_length_m=roll_length_m,
|
||||
width_m=width_m,
|
||||
thickness_mm=thickness_mm,
|
||||
thickness_stddev_mm=thickness_stddev_mm,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
core_type=core_type,
|
||||
category=category,
|
||||
production_site=production_site,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def analyze_transport_capacity(
|
||||
transport_preset: TransportPresetKey,
|
||||
roll_diameter_mm: float,
|
||||
core_diameter_mm: float,
|
||||
roll_width_m: float,
|
||||
roll_weight_kg: float,
|
||||
product_length_m: float,
|
||||
) -> dict[str, Any]:
|
||||
"""Use this tool when the user asks how many rolls fit on a known RollCalc transport type. A named transport type is already fully defined by its RollCalc preset: do not ask the user for vehicle dimensions or payload, and do not estimate or invent vehicle data. Use these canonical mappings: LKW-Sattelzug -> lkw_sattelzug; LKW-Tandem -> lkw_tandem; 20ft Container -> container_20ft; 40ft Container -> container_40ft; 40ft High Cube -> container_40ft_hc. If the user asks how many rolls fit on an LKW-Sattelzug, call this tool with transport_preset=\"lkw_sattelzug\". For a chained request, first call get_article and calculate_roll_diameter, then pass every field from its transport_roll_inputs bundle unchanged: roll_diameter_mm, core_diameter_mm, roll_width_m, roll_weight_kg, and product_length_m. Do not choose among minimum, average, or maximum diameter; the bundle contains the browser-equivalent nominal transport diameter. product_length_m is used for reported m², not placement geometry. This stateless tool does not recalculate a roll; roll_weight_kg is consumed exactly as supplied."""
|
||||
return analyze_transport_capacity_result(
|
||||
transport_preset=transport_preset,
|
||||
roll_diameter_mm=roll_diameter_mm,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
roll_width_m=roll_width_m,
|
||||
roll_weight_kg=roll_weight_kg,
|
||||
product_length_m=product_length_m,
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_server().run(transport="stdio")
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
"""Strict Ollama-backed natural-language interpretation for RollCalc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434"
|
||||
DEFAULT_OLLAMA_MODEL = "qwen3.5:35B-A3B"
|
||||
DEFAULT_TIMEOUT_SECONDS = 45.0
|
||||
DEFAULT_MAX_RESPONSE_BYTES = 1_000_000
|
||||
MAX_HTTP_ERROR_BODY_BYTES = 4_096
|
||||
MAX_HTTP_ERROR_DIAGNOSTIC_CHARS = 1_000
|
||||
|
||||
NEW_CALCULATION_FIELDS = {
|
||||
"intent",
|
||||
"article_number",
|
||||
"article_name_hint",
|
||||
"roll_length_m",
|
||||
"width_m",
|
||||
"core_type",
|
||||
"core_diameter_mm",
|
||||
"include_roll_weight",
|
||||
}
|
||||
CHANGE_FIELDS = {
|
||||
"article_number",
|
||||
"article_name_hint",
|
||||
"roll_length_m",
|
||||
"width_m",
|
||||
"core_type",
|
||||
"core_diameter_mm",
|
||||
"include_roll_weight",
|
||||
}
|
||||
|
||||
|
||||
NLU_JSON_SCHEMA: dict[str, Any] = {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"intent": {"const": "new_calculation"},
|
||||
"article_number": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{"type": "string", "minLength": 1},
|
||||
]
|
||||
},
|
||||
"article_name_hint": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{"type": "string", "minLength": 1},
|
||||
]
|
||||
},
|
||||
"roll_length_m": {
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"width_m": {
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"core_type": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{"type": "string", "minLength": 1},
|
||||
]
|
||||
},
|
||||
"core_diameter_mm": {
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"include_roll_weight": {"type": "boolean"},
|
||||
},
|
||||
"required": sorted(NEW_CALCULATION_FIELDS),
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"intent": {"const": "modify_calculation"},
|
||||
"changes": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"article_number": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
},
|
||||
"article_name_hint": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
},
|
||||
"roll_length_m": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"width_m": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"core_type": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
},
|
||||
"core_diameter_mm": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"include_roll_weight": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["intent", "changes"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"intent": {"const": "unsupported"}},
|
||||
"required": ["intent"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class NLUError(RuntimeError):
|
||||
"""Base error for controlled NLU failures."""
|
||||
|
||||
|
||||
class NLUValidationError(NLUError):
|
||||
"""Raised when model output is not valid under the narrow NLU schema."""
|
||||
|
||||
|
||||
class OllamaUnavailableError(NLUError):
|
||||
"""Raised when the configured local Ollama service cannot be reached."""
|
||||
|
||||
|
||||
class OllamaTimeoutError(NLUError):
|
||||
"""Raised when Ollama does not answer within the configured timeout."""
|
||||
|
||||
|
||||
class OllamaResponseError(NLUError):
|
||||
"""Raised when Ollama returns an unsuccessful or malformed response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OllamaConfig:
|
||||
base_url: str = DEFAULT_OLLAMA_URL
|
||||
model: str = DEFAULT_OLLAMA_MODEL
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
||||
temperature: float = 0.0
|
||||
think: bool = False
|
||||
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OllamaConfig":
|
||||
return cls(
|
||||
base_url=os.getenv("ROLLCALC_OLLAMA_URL", DEFAULT_OLLAMA_URL),
|
||||
model=os.getenv("ROLLCALC_OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL),
|
||||
timeout_seconds=float(
|
||||
os.getenv(
|
||||
"ROLLCALC_OLLAMA_TIMEOUT_SECONDS",
|
||||
str(DEFAULT_TIMEOUT_SECONDS),
|
||||
)
|
||||
),
|
||||
temperature=float(os.getenv("ROLLCALC_OLLAMA_TEMPERATURE", "0")),
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.base_url.strip():
|
||||
raise ValueError("Ollama base URL is required")
|
||||
if not self.model.strip():
|
||||
raise ValueError("Ollama model is required")
|
||||
if not math.isfinite(self.timeout_seconds) or self.timeout_seconds <= 0:
|
||||
raise ValueError("Ollama timeout must be greater than zero")
|
||||
if not math.isfinite(self.temperature) or self.temperature < 0:
|
||||
raise ValueError("Ollama temperature must be zero or greater")
|
||||
if self.max_response_bytes <= 0:
|
||||
raise ValueError("Ollama response limit must be greater than zero")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NLUInterpretation:
|
||||
intent: str
|
||||
article_number: str | None = None
|
||||
article_name_hint: str | None = None
|
||||
roll_length_m: float | None = None
|
||||
width_m: float | None = None
|
||||
core_type: str | None = None
|
||||
core_diameter_mm: float | None = None
|
||||
include_roll_weight: bool = False
|
||||
changes: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
if self.intent == "modify_calculation":
|
||||
return {"intent": self.intent, "changes": dict(self.changes or {})}
|
||||
if self.intent == "unsupported":
|
||||
return {"intent": self.intent}
|
||||
result = asdict(self)
|
||||
result.pop("changes")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NLUResponse:
|
||||
interpretation: NLUInterpretation
|
||||
raw_model_json: str
|
||||
latency_ms: float
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClarificationInterpretation:
|
||||
status: str
|
||||
field: str
|
||||
value: Any = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = {"status": self.status, "field": self.field}
|
||||
if self.status == "parsed":
|
||||
result["value"] = self.value
|
||||
return result
|
||||
|
||||
|
||||
_CLARIFICATION_UNITS = {
|
||||
"roll_length_m": {"", "m", "meter", "metern"},
|
||||
"width_m": {"", "m", "meter", "metern"},
|
||||
"thickness_mm": {"", "mm", "millimeter", "millimetern"},
|
||||
"core_diameter_mm": {"", "mm", "millimeter", "millimetern"},
|
||||
"area_weight_g_m2": {"", "g/m2", "gsm"},
|
||||
}
|
||||
_CLARIFICATION_TEXT_FIELDS = {"article_number", "article_name_hint", "core_type"}
|
||||
_CLARIFICATION_NUMBER = re.compile(
|
||||
r"^\s*(?P<number>\d+(?:[.,]\d+)?)\s*"
|
||||
r"(?P<unit>[A-Za-zÀ-ÖØ-öø-ÿ²/^0-9]+)?\s*[.!]?\s*$"
|
||||
)
|
||||
|
||||
|
||||
def parse_clarification_reply(
|
||||
text: str,
|
||||
expected_field: str,
|
||||
) -> ClarificationInterpretation:
|
||||
"""Parse one reply only as the application-selected clarification field."""
|
||||
supported_fields = set(_CLARIFICATION_UNITS) | _CLARIFICATION_TEXT_FIELDS
|
||||
if expected_field not in supported_fields:
|
||||
raise NLUValidationError("unsupported clarification field")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
|
||||
if expected_field in _CLARIFICATION_TEXT_FIELDS:
|
||||
try:
|
||||
value = _required_text(text, expected_field)
|
||||
except NLUValidationError:
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
return ClarificationInterpretation("parsed", expected_field, value)
|
||||
|
||||
match = _CLARIFICATION_NUMBER.fullmatch(text)
|
||||
if match is None:
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
unit = (match.group("unit") or "").casefold()
|
||||
unit = unit.replace("²", "2").replace("^", "")
|
||||
if unit not in _CLARIFICATION_UNITS[expected_field]:
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
value = float(match.group("number").replace(",", "."))
|
||||
try:
|
||||
value = _positive_number(value, expected_field, optional=False)
|
||||
except NLUValidationError:
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
return ClarificationInterpretation("parsed", expected_field, value)
|
||||
|
||||
|
||||
def _optional_text(value: Any, field: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise NLUValidationError(f"{field} must be text or null")
|
||||
value = " ".join(value.split()).strip()
|
||||
if not value:
|
||||
return None
|
||||
if len(value) > 240:
|
||||
raise NLUValidationError(f"{field} is too long")
|
||||
return value
|
||||
|
||||
|
||||
def _required_text(value: Any, field: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise NLUValidationError(f"{field} must be text")
|
||||
value = " ".join(value.split()).strip()
|
||||
if not value:
|
||||
raise NLUValidationError(f"{field} must not be empty")
|
||||
if len(value) > 240:
|
||||
raise NLUValidationError(f"{field} is too long")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_number(value: Any, field: str, *, optional: bool) -> float | None:
|
||||
if value is None and optional:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
suffix = " or null" if optional else ""
|
||||
raise NLUValidationError(f"{field} must be a number{suffix}")
|
||||
value = float(value)
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise NLUValidationError(f"{field} must be greater than zero")
|
||||
return value
|
||||
|
||||
|
||||
def validate_nlu_payload(payload: Any) -> NLUInterpretation:
|
||||
"""Validate untrusted model output without coercing or inferring values."""
|
||||
if not isinstance(payload, dict):
|
||||
raise NLUValidationError("model output must be a JSON object")
|
||||
intent = payload.get("intent")
|
||||
if intent not in {"new_calculation", "modify_calculation", "unsupported"}:
|
||||
raise NLUValidationError("unsupported or missing NLU intent")
|
||||
|
||||
if intent == "unsupported":
|
||||
unknown = sorted(set(payload) - {"intent"})
|
||||
if unknown:
|
||||
raise NLUValidationError(
|
||||
"unknown unsupported-intent fields: " + ", ".join(unknown)
|
||||
)
|
||||
return NLUInterpretation(intent="unsupported")
|
||||
|
||||
if intent == "new_calculation":
|
||||
unknown = sorted(set(payload) - NEW_CALCULATION_FIELDS)
|
||||
missing = sorted(NEW_CALCULATION_FIELDS - set(payload))
|
||||
if unknown:
|
||||
raise NLUValidationError(
|
||||
"unknown new-calculation fields: " + ", ".join(unknown)
|
||||
)
|
||||
if missing:
|
||||
raise NLUValidationError(
|
||||
"missing new-calculation fields: " + ", ".join(missing)
|
||||
)
|
||||
if not isinstance(payload["include_roll_weight"], bool):
|
||||
raise NLUValidationError("include_roll_weight must be boolean")
|
||||
article_number = _optional_text(payload["article_number"], "article_number")
|
||||
return NLUInterpretation(
|
||||
intent=intent,
|
||||
article_number=article_number,
|
||||
article_name_hint=_optional_text(
|
||||
payload["article_name_hint"], "article_name_hint"
|
||||
),
|
||||
roll_length_m=_positive_number(
|
||||
payload["roll_length_m"], "roll_length_m", optional=True
|
||||
),
|
||||
width_m=_positive_number(payload["width_m"], "width_m", optional=True),
|
||||
core_type=_optional_text(payload["core_type"], "core_type"),
|
||||
core_diameter_mm=_positive_number(
|
||||
payload["core_diameter_mm"], "core_diameter_mm", optional=True
|
||||
),
|
||||
include_roll_weight=payload["include_roll_weight"],
|
||||
)
|
||||
|
||||
unknown = sorted(set(payload) - {"intent", "changes"})
|
||||
if unknown:
|
||||
raise NLUValidationError("unknown modification fields: " + ", ".join(unknown))
|
||||
changes = payload.get("changes")
|
||||
if not isinstance(changes, dict) or not changes:
|
||||
raise NLUValidationError("changes must be a non-empty JSON object")
|
||||
unknown_changes = sorted(set(changes) - CHANGE_FIELDS)
|
||||
if unknown_changes:
|
||||
raise NLUValidationError("unknown change fields: " + ", ".join(unknown_changes))
|
||||
|
||||
validated_changes: dict[str, Any] = {}
|
||||
for field, value in changes.items():
|
||||
if field in {"roll_length_m", "width_m", "core_diameter_mm"}:
|
||||
validated_changes[field] = _positive_number(value, field, optional=False)
|
||||
elif field == "include_roll_weight":
|
||||
if not isinstance(value, bool):
|
||||
raise NLUValidationError("include_roll_weight must be boolean")
|
||||
validated_changes[field] = value
|
||||
else:
|
||||
validated_changes[field] = _required_text(value, field)
|
||||
return NLUInterpretation(
|
||||
intent="modify_calculation",
|
||||
changes=validated_changes,
|
||||
)
|
||||
|
||||
|
||||
def parse_nlu_json(raw_model_json: str) -> NLUInterpretation:
|
||||
if not isinstance(raw_model_json, str) or not raw_model_json.strip():
|
||||
raise NLUValidationError("model output is empty")
|
||||
try:
|
||||
payload = json.loads(raw_model_json)
|
||||
except json.JSONDecodeError as error:
|
||||
raise NLUValidationError("model output is not valid JSON") from error
|
||||
return validate_nlu_payload(payload)
|
||||
|
||||
|
||||
def _http_error_diagnostic(error: HTTPError) -> str:
|
||||
try:
|
||||
raw_body = error.read(MAX_HTTP_ERROR_BODY_BYTES + 1)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not raw_body:
|
||||
return ""
|
||||
if isinstance(raw_body, bytes):
|
||||
body = raw_body.decode("utf-8", errors="replace")
|
||||
else:
|
||||
body = str(raw_body)
|
||||
body = " ".join(body.split())
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
diagnostic = body
|
||||
try:
|
||||
parsed_body = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
if isinstance(parsed_body, dict):
|
||||
ollama_error = parsed_body.get("error")
|
||||
if isinstance(ollama_error, str) and ollama_error.strip():
|
||||
diagnostic = " ".join(ollama_error.split())
|
||||
|
||||
if len(diagnostic) > MAX_HTTP_ERROR_DIAGNOSTIC_CHARS:
|
||||
diagnostic = diagnostic[: MAX_HTTP_ERROR_DIAGNOSTIC_CHARS - 3] + "..."
|
||||
return diagnostic
|
||||
|
||||
|
||||
def _system_prompt(
|
||||
*,
|
||||
has_state: bool,
|
||||
expected_fields: tuple[str, ...],
|
||||
current_state: dict[str, Any] | None,
|
||||
) -> str:
|
||||
context = {
|
||||
"has_calculation_state": has_state,
|
||||
"expected_clarification_fields": list(expected_fields),
|
||||
"current_calculation": current_state,
|
||||
}
|
||||
return (
|
||||
"You are the strictly constrained NLU adapter for RollCalc. "
|
||||
"Interpret the user's German or English text; never calculate, estimate, "
|
||||
"or invent engineering values, product data, warnings, defaults, or core "
|
||||
"mappings. Extract a user-provided product phrase only as article_name_hint; "
|
||||
"never invent, rank, or select article candidates. Return only JSON matching "
|
||||
"the supplied schema. Preserve article "
|
||||
"numbers as strings. Convert an explicitly written German decimal comma to "
|
||||
"a JSON number. For every optional textual field the user did not provide, "
|
||||
"return JSON null; never return an empty or whitespace-only string. Set "
|
||||
"include_roll_weight=true only when the user explicitly asks for roll weight. "
|
||||
"Use modify_calculation for follow-ups that change existing structured state; "
|
||||
"put every explicitly requested changed input into changes. "
|
||||
"When current_calculation is present, references such as die Rolle, dieselbe "
|
||||
"Rolle, sie, bei 80 m, mit 5 m Breite, wie schwer ist sie, or nimm einen "
|
||||
"Stahlkern refer to that calculation and must not discard its other inputs. "
|
||||
"Use new_calculation only when the user explicitly starts a new calculation "
|
||||
"or identifies a different article. Do not copy unchanged context values into "
|
||||
"the output. "
|
||||
"If expected clarification fields are listed, interpret a short answer only "
|
||||
"against those fields. Use unsupported for every other task. "
|
||||
"Never emit diameter results, weight results, warnings, explanations, or "
|
||||
"markdown. Technical context: "
|
||||
+ json.dumps(context, ensure_ascii=False, separators=(",", ":"))
|
||||
+ ". Required JSON schema: "
|
||||
+ json.dumps(NLU_JSON_SCHEMA, ensure_ascii=False, separators=(",", ":"))
|
||||
)
|
||||
|
||||
|
||||
class OllamaNLUClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: OllamaConfig | None = None,
|
||||
*,
|
||||
opener: Callable[..., Any] = urlopen,
|
||||
) -> None:
|
||||
self.config = config or OllamaConfig.from_env()
|
||||
self._opener = opener
|
||||
|
||||
def interpret(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
has_state: bool = False,
|
||||
expected_fields: tuple[str, ...] = (),
|
||||
current_state: dict[str, Any] | None = None,
|
||||
) -> NLUResponse:
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise NLUValidationError("message must be non-empty text")
|
||||
if len(text) > 2_000:
|
||||
raise NLUValidationError("message is too long")
|
||||
|
||||
payload = {
|
||||
"model": self.config.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": _system_prompt(
|
||||
has_state=has_state,
|
||||
expected_fields=expected_fields,
|
||||
current_state=current_state,
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": text.strip()},
|
||||
],
|
||||
"stream": False,
|
||||
"think": self.config.think,
|
||||
"format": NLU_JSON_SCHEMA,
|
||||
"options": {
|
||||
"temperature": self.config.temperature,
|
||||
"num_predict": 300,
|
||||
},
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = Request(
|
||||
self.config.base_url.rstrip("/") + "/api/chat",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with self._opener(request, timeout=self.config.timeout_seconds) as response:
|
||||
raw_response = response.read(self.config.max_response_bytes + 1)
|
||||
except (socket.timeout, TimeoutError) as error:
|
||||
raise OllamaTimeoutError("Ollama request timed out") from error
|
||||
except HTTPError as error:
|
||||
diagnostic = _http_error_diagnostic(error)
|
||||
message = f"Ollama returned HTTP {error.code}"
|
||||
if diagnostic:
|
||||
message += f": {diagnostic}"
|
||||
raise OllamaResponseError(
|
||||
message,
|
||||
status_code=error.code,
|
||||
) from error
|
||||
except URLError as error:
|
||||
if isinstance(error.reason, (socket.timeout, TimeoutError)):
|
||||
raise OllamaTimeoutError("Ollama request timed out") from error
|
||||
raise OllamaUnavailableError("Ollama service is unavailable") from error
|
||||
except OSError as error:
|
||||
raise OllamaUnavailableError("Ollama service is unavailable") from error
|
||||
latency_ms = (time.monotonic() - started) * 1_000
|
||||
|
||||
if len(raw_response) > self.config.max_response_bytes:
|
||||
raise OllamaResponseError("Ollama response is too large")
|
||||
try:
|
||||
response_payload = json.loads(raw_response.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise OllamaResponseError("Ollama returned invalid JSON") from error
|
||||
message = (
|
||||
response_payload.get("message")
|
||||
if isinstance(response_payload, dict)
|
||||
else None
|
||||
)
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if not isinstance(content, str):
|
||||
raise OllamaResponseError("Ollama response has no message content")
|
||||
|
||||
interpretation = parse_nlu_json(content)
|
||||
return NLUResponse(
|
||||
interpretation=interpretation,
|
||||
raw_model_json=content,
|
||||
latency_ms=latency_ms,
|
||||
model=self.config.model,
|
||||
)
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
"""Validated, dependency-free PDF rendering for roll calculation reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
A4_WIDTH = 595.28
|
||||
A4_HEIGHT = 841.89
|
||||
|
||||
|
||||
class ReportValidationError(ValueError):
|
||||
"""Raised when report input is incomplete or unsafe to render."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiameterValue:
|
||||
value: float
|
||||
display: str
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, required: bool = True, limit: int = 240) -> str:
|
||||
if value is None:
|
||||
value = ""
|
||||
if not isinstance(value, str):
|
||||
raise ReportValidationError(f"{field} must be text")
|
||||
value = " ".join(value.split()).strip()
|
||||
if required and not value:
|
||||
raise ReportValidationError(f"{field} is required")
|
||||
if len(value) > limit:
|
||||
raise ReportValidationError(f"{field} is too long")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_number(value: Any, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ReportValidationError(f"{field} must be a number")
|
||||
value = float(value)
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ReportValidationError(f"{field} must be greater than zero")
|
||||
return value
|
||||
|
||||
|
||||
def _measurement_display(
|
||||
value: float, display: str, unit: str, field: str, *, decimals: int = 1
|
||||
) -> str:
|
||||
match = re.fullmatch(
|
||||
rf"([0-9]+\.[0-9]{{{decimals}}}) {re.escape(unit)}", display
|
||||
)
|
||||
tolerance = (0.5 * (10 ** -decimals)) + 0.000001
|
||||
if not match or abs(float(match.group(1)) - value) > tolerance:
|
||||
raise ReportValidationError(f"{field} does not match its numeric value")
|
||||
return display
|
||||
|
||||
|
||||
def validate_report_payload(payload: Any) -> dict[str, Any]:
|
||||
"""Validate the internal PDF layout model produced from a calculation result."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ReportValidationError("JSON object required")
|
||||
if payload.get("report_type") != "roll_diameter":
|
||||
raise ReportValidationError("unsupported or incomplete calculation result")
|
||||
|
||||
raw_diameters = payload.get("diameters")
|
||||
if not isinstance(raw_diameters, dict):
|
||||
raise ReportValidationError("diameters are required")
|
||||
|
||||
diameters: dict[str, DiameterValue] = {}
|
||||
for key in ("minimum", "average", "maximum"):
|
||||
raw = raw_diameters.get(key)
|
||||
if not isinstance(raw, dict):
|
||||
raise ReportValidationError(f"diameters.{key} is required")
|
||||
value = _positive_number(raw.get("value"), f"diameters.{key}.value")
|
||||
display_field = f"diameters.{key}.display"
|
||||
display = _text(raw.get("display"), display_field, limit=40)
|
||||
display = _measurement_display(value, display, "mm", display_field)
|
||||
diameters[key] = DiameterValue(value=value, display=display)
|
||||
|
||||
if not (
|
||||
diameters["minimum"].value
|
||||
<= diameters["average"].value
|
||||
<= diameters["maximum"].value
|
||||
):
|
||||
raise ReportValidationError("diameter values must be ordered minimum to maximum")
|
||||
|
||||
raw_length = payload.get("roll_length")
|
||||
if not isinstance(raw_length, dict):
|
||||
raise ReportValidationError("roll_length is required")
|
||||
length_value = _positive_number(raw_length.get("value"), "roll_length.value")
|
||||
length_display = _text(
|
||||
raw_length.get("display"), "roll_length.display", limit=40
|
||||
)
|
||||
roll_length = {
|
||||
"value": length_value,
|
||||
"display": _measurement_display(
|
||||
length_value,
|
||||
length_display,
|
||||
"m",
|
||||
"roll_length.display",
|
||||
decimals=2,
|
||||
),
|
||||
}
|
||||
|
||||
raw_inputs = payload.get("inputs")
|
||||
if not isinstance(raw_inputs, list) or not 3 <= len(raw_inputs) <= 16:
|
||||
raise ReportValidationError("between 3 and 16 calculation inputs are required")
|
||||
inputs = []
|
||||
for index, item in enumerate(raw_inputs):
|
||||
if not isinstance(item, dict):
|
||||
raise ReportValidationError(f"inputs[{index}] must be an object")
|
||||
inputs.append(
|
||||
{
|
||||
"label": _text(item.get("label"), f"inputs[{index}].label", limit=60),
|
||||
"value": _text(item.get("value"), f"inputs[{index}].value", limit=100),
|
||||
}
|
||||
)
|
||||
|
||||
article = None
|
||||
raw_article = payload.get("article")
|
||||
if raw_article is not None:
|
||||
if not isinstance(raw_article, dict):
|
||||
raise ReportValidationError("article must be an object")
|
||||
number = _text(raw_article.get("number"), "article.number", required=False, limit=60)
|
||||
name = _text(raw_article.get("name"), "article.name", required=False, limit=240)
|
||||
if number or name:
|
||||
article = {"number": number, "name": name}
|
||||
|
||||
roll_weight = None
|
||||
raw_weight = payload.get("roll_weight")
|
||||
if raw_weight is not None:
|
||||
if not isinstance(raw_weight, dict):
|
||||
raise ReportValidationError("roll_weight must be an object")
|
||||
weight_value = _positive_number(raw_weight.get("value"), "roll_weight.value")
|
||||
weight_display = _text(
|
||||
raw_weight.get("display"), "roll_weight.display", limit=40
|
||||
)
|
||||
roll_weight = {
|
||||
"value": weight_value,
|
||||
"display": _measurement_display(
|
||||
weight_value, weight_display, "kg", "roll_weight.display"
|
||||
),
|
||||
}
|
||||
|
||||
warnings = []
|
||||
raw_warnings = payload.get("warnings", [])
|
||||
if not isinstance(raw_warnings, list) or len(raw_warnings) > 12:
|
||||
raise ReportValidationError("warnings must be a list of at most 12 items")
|
||||
for index, item in enumerate(raw_warnings):
|
||||
if not isinstance(item, dict):
|
||||
raise ReportValidationError(f"warnings[{index}] must be an object")
|
||||
warnings.append(
|
||||
{
|
||||
"title": _text(item.get("title"), f"warnings[{index}].title", limit=120),
|
||||
"message": _text(item.get("message"), f"warnings[{index}].message", limit=500),
|
||||
"severity": (
|
||||
item.get("severity")
|
||||
if item.get("severity") in {"warning", "critical"}
|
||||
else "warning"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
notes = []
|
||||
raw_notes = payload.get("notes", [])
|
||||
if not isinstance(raw_notes, list) or len(raw_notes) > 12:
|
||||
raise ReportValidationError("notes must be a list of at most 12 items")
|
||||
for index, item in enumerate(raw_notes):
|
||||
notes.append(_text(item, f"notes[{index}]", limit=500))
|
||||
|
||||
report_text_size = sum(
|
||||
len(item["title"]) + len(item["message"]) for item in warnings
|
||||
) + sum(len(note) for note in notes)
|
||||
if report_text_size > 1800:
|
||||
raise ReportValidationError("warnings and notes are too long for a one-page report")
|
||||
|
||||
return {
|
||||
"article": article,
|
||||
"inputs": inputs,
|
||||
"diameters": diameters,
|
||||
"roll_length": roll_length,
|
||||
"roll_weight": roll_weight,
|
||||
"warnings": warnings,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
def safe_report_filename(article_number: str | None, generated_at: datetime) -> str:
|
||||
reference = re.sub(r"[^A-Za-z0-9._-]+", "-", article_number or "calculation")
|
||||
reference = reference.strip("-._")[:60] or "calculation"
|
||||
return f"roll-calculation_{reference}_{generated_at:%Y-%m-%d_%H%M%S}.pdf"
|
||||
|
||||
|
||||
def report_from_calculation_result(result: Any) -> dict[str, Any]:
|
||||
"""Adapt an authoritative calculation result to the existing PDF model."""
|
||||
if not isinstance(result, dict) or result.get("status") != "success":
|
||||
raise ReportValidationError("a successful calculation result is required")
|
||||
calculation = result.get("calculation")
|
||||
effective = result.get("effective_inputs")
|
||||
request = result.get("request")
|
||||
provenance = result.get("provenance")
|
||||
if not all(isinstance(item, dict) for item in (calculation, effective, request, provenance)):
|
||||
raise ReportValidationError("incomplete authoritative calculation result")
|
||||
if provenance.get("calculator") != "roll_calculation.calculate_roll":
|
||||
raise ReportValidationError("unknown calculation provenance")
|
||||
|
||||
def effective_value(field: str) -> Any:
|
||||
item = effective.get(field)
|
||||
if not isinstance(item, dict) or "value" not in item:
|
||||
raise ReportValidationError(f"effective input {field} is required")
|
||||
return item["value"]
|
||||
|
||||
length = effective_value("roll_length_m")
|
||||
core_diameter = effective_value("core_diameter_mm")
|
||||
core_type = effective_value("core_type")
|
||||
thickness = effective_value("thickness_mm")
|
||||
thickness_stddev = effective_value("thickness_stddev_mm")
|
||||
inputs = [
|
||||
{"label": "Core diameter", "value": f"{core_diameter:.1f} mm"},
|
||||
{"label": "Core type", "value": str(core_type)},
|
||||
{"label": "Product thickness", "value": f"{thickness:.3f} mm"},
|
||||
{"label": "Thickness stddev", "value": f"{thickness_stddev:.3f} mm"},
|
||||
{"label": "Product length", "value": f"{length:.2f} m"},
|
||||
]
|
||||
if request.get("category"):
|
||||
inputs.append({"label": "Product category", "value": request["category"]})
|
||||
if request.get("production_site"):
|
||||
inputs.append({"label": "Production site", "value": request["production_site"]})
|
||||
|
||||
weight = calculation.get("roll_weight_kg")
|
||||
if weight is not None:
|
||||
width = effective_value("width_m")
|
||||
area_weight = effective_value("area_weight_g_m2")
|
||||
inputs.extend(
|
||||
(
|
||||
{"label": "Roll width", "value": f"{width:.2f} m"},
|
||||
{"label": "Area weight", "value": f"{area_weight:.1f} g/m²"},
|
||||
)
|
||||
)
|
||||
|
||||
article = result.get("resolved_article")
|
||||
raw_report = {
|
||||
"report_type": "roll_diameter",
|
||||
"article": (
|
||||
{"number": article.get("number", ""), "name": article.get("name", "")}
|
||||
if isinstance(article, dict)
|
||||
else None
|
||||
),
|
||||
"inputs": inputs,
|
||||
"diameters": {
|
||||
"minimum": {
|
||||
"value": calculation.get("minimum_diameter_mm"),
|
||||
"display": f"{calculation.get('minimum_diameter_mm'):.1f} mm",
|
||||
},
|
||||
"average": {
|
||||
"value": calculation.get("average_diameter_mm"),
|
||||
"display": f"{calculation.get('average_diameter_mm'):.1f} mm",
|
||||
},
|
||||
"maximum": {
|
||||
"value": calculation.get("maximum_diameter_mm"),
|
||||
"display": f"{calculation.get('maximum_diameter_mm'):.1f} mm",
|
||||
},
|
||||
},
|
||||
"roll_length": {"value": length, "display": f"{length:.2f} m"},
|
||||
"roll_weight": (
|
||||
{"value": weight, "display": f"{weight:.1f} kg"}
|
||||
if weight is not None
|
||||
else None
|
||||
),
|
||||
"warnings": result.get("warnings", []),
|
||||
"notes": result.get("notes", []),
|
||||
}
|
||||
return validate_report_payload(raw_report)
|
||||
|
||||
|
||||
def _pdf_escape(value: str) -> bytes:
|
||||
value = value.translate(
|
||||
str.maketrans(
|
||||
{
|
||||
"≥": ">=",
|
||||
"≤": "<=",
|
||||
"−": "-",
|
||||
"σ": "sigma",
|
||||
"π": "pi",
|
||||
"✓": "OK",
|
||||
"✗": "X",
|
||||
}
|
||||
)
|
||||
)
|
||||
encoded = value.encode("cp1252", errors="replace")
|
||||
return encoded.replace(b"\\", b"\\\\").replace(b"(", b"\\(").replace(b")", b"\\)")
|
||||
|
||||
|
||||
def _wrap(value: str, width: float, font_size: float) -> list[str]:
|
||||
max_chars = max(12, int(width / (font_size * 0.52)))
|
||||
words = value.split()
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for word in words:
|
||||
while len(word) > max_chars:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = ""
|
||||
lines.append(word[: max_chars - 1] + "-")
|
||||
word = word[max_chars - 1 :]
|
||||
candidate = f"{current} {word}".strip()
|
||||
if len(candidate) <= max_chars:
|
||||
current = candidate
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current or not lines:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
class _Canvas:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[bytes] = []
|
||||
|
||||
def raw(self, command: str) -> None:
|
||||
self.commands.append(command.encode("ascii"))
|
||||
|
||||
def text(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
value: str,
|
||||
*,
|
||||
size: float = 9,
|
||||
bold: bool = False,
|
||||
color: tuple[float, float, float] = (0.16, 0.20, 0.18),
|
||||
) -> None:
|
||||
font = "F2" if bold else "F1"
|
||||
prefix = (
|
||||
f"BT /{font} {size:.2f} Tf "
|
||||
f"{color[0]:.3f} {color[1]:.3f} {color[2]:.3f} rg "
|
||||
f"1 0 0 1 {x:.2f} {y:.2f} Tm ("
|
||||
).encode("ascii")
|
||||
self.commands.append(prefix + _pdf_escape(value) + b") Tj ET")
|
||||
|
||||
def line(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
*,
|
||||
color=(0.8, 0.8, 0.8),
|
||||
width=0.7,
|
||||
) -> None:
|
||||
self.raw(
|
||||
f"{color[0]:.3f} {color[1]:.3f} {color[2]:.3f} RG {width:.2f} w "
|
||||
f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S"
|
||||
)
|
||||
|
||||
def rect(
|
||||
self, x: float, y: float, width: float, height: float, *, fill, stroke=None
|
||||
) -> None:
|
||||
command = f"{fill[0]:.3f} {fill[1]:.3f} {fill[2]:.3f} rg "
|
||||
if stroke:
|
||||
command += f"{stroke[0]:.3f} {stroke[1]:.3f} {stroke[2]:.3f} RG 0.8 w "
|
||||
command += f"{x:.2f} {y:.2f} {width:.2f} {height:.2f} re {'B' if stroke else 'f'}"
|
||||
self.raw(command)
|
||||
|
||||
def stream(self) -> bytes:
|
||||
return b"\n".join(self.commands) + b"\n"
|
||||
|
||||
|
||||
def _make_pdf(content: bytes) -> bytes:
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
(
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] "
|
||||
b"/Resources << /Font << /F1 4 0 R /F2 5 0 R >> /ExtGState << /GS1 6 0 R >> >> "
|
||||
b"/Contents 7 0 R >>"
|
||||
),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
|
||||
b"<< /Type /ExtGState /ca 0.085 /CA 0.085 >>",
|
||||
(
|
||||
b"<< /Length "
|
||||
+ str(len(content)).encode("ascii")
|
||||
+ b" >>\nstream\n"
|
||||
+ content
|
||||
+ b"endstream"
|
||||
),
|
||||
]
|
||||
|
||||
pdf = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
offsets = [0]
|
||||
for number, obj in enumerate(objects, start=1):
|
||||
offsets.append(len(pdf))
|
||||
pdf.extend(f"{number} 0 obj\n".encode("ascii"))
|
||||
pdf.extend(obj)
|
||||
pdf.extend(b"\nendobj\n")
|
||||
xref = len(pdf)
|
||||
pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
|
||||
pdf.extend(b"0000000000 65535 f \n")
|
||||
for offset in offsets[1:]:
|
||||
pdf.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
|
||||
pdf.extend(
|
||||
(
|
||||
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
|
||||
f"startxref\n{xref}\n%%EOF\n"
|
||||
).encode("ascii")
|
||||
)
|
||||
return bytes(pdf)
|
||||
|
||||
|
||||
def render_roll_report(
|
||||
report: dict[str, Any],
|
||||
*,
|
||||
generated_at: datetime,
|
||||
build_info: dict[str, str],
|
||||
) -> bytes:
|
||||
"""Render a validated report model as a deliberately compact one-page PDF."""
|
||||
canvas = _Canvas()
|
||||
green = (0.10, 0.43, 0.28)
|
||||
pale_green = (0.91, 0.97, 0.94)
|
||||
dark = (0.12, 0.18, 0.15)
|
||||
muted = (0.38, 0.43, 0.40)
|
||||
margin = 40
|
||||
usable_width = A4_WIDTH - (2 * margin)
|
||||
|
||||
canvas.rect(0, A4_HEIGHT - 92, A4_WIDTH, 92, fill=(0.08, 0.29, 0.20))
|
||||
canvas.text(margin, 805, "ROLL CALCULATOR", size=20, bold=True, color=(1, 1, 1))
|
||||
canvas.text(margin, 787, "Calculation report", size=10, color=(0.82, 0.93, 0.87))
|
||||
generated_text = generated_at.astimezone().strftime("Generated %Y-%m-%d %H:%M %Z")
|
||||
canvas.text(370, 805, generated_text, size=8, color=(0.88, 0.95, 0.91))
|
||||
canvas.text(
|
||||
370,
|
||||
790,
|
||||
(
|
||||
f"Version {build_info.get('version', 'unknown')} | "
|
||||
f"Build {build_info.get('commit', 'unknown')}"
|
||||
),
|
||||
size=8,
|
||||
color=(0.88, 0.95, 0.91),
|
||||
)
|
||||
|
||||
article = report["article"]
|
||||
y = 726
|
||||
if article:
|
||||
article_heading = " | ".join(
|
||||
part for part in (article["number"], article["name"]) if part
|
||||
)
|
||||
canvas.text(margin, y + 13, "ARTICLE", size=7.5, bold=True, color=green)
|
||||
article_lines = _wrap(article_heading, usable_width, 11)
|
||||
if len(article_lines) > 2:
|
||||
article_lines[1] = article_lines[1].rstrip("- ") + "…"
|
||||
for line in article_lines[:2]:
|
||||
canvas.text(margin, y, line, size=11, bold=True, color=dark)
|
||||
y -= 14
|
||||
y -= 5
|
||||
|
||||
canvas.text(margin, y, "CALCULATION RESULTS", size=8, bold=True, color=green)
|
||||
y -= 67
|
||||
card_gap = 10
|
||||
card_width = (usable_width - 3 * card_gap) / 4
|
||||
result_cards = (
|
||||
(("MINIMUM", "DIAMETER"), report["diameters"]["minimum"].display),
|
||||
(("AVERAGE", "DIAMETER"), report["diameters"]["average"].display),
|
||||
(("MAXIMUM", "DIAMETER"), report["diameters"]["maximum"].display),
|
||||
(("ROLL", "LENGTH"), report["roll_length"]["display"]),
|
||||
)
|
||||
for index, (label_lines, display) in enumerate(result_cards):
|
||||
x = margin + index * (card_width + card_gap)
|
||||
canvas.rect(x, y, card_width, 54, fill=pale_green, stroke=(0.55, 0.77, 0.65))
|
||||
canvas.text(x + 10, y + 39, label_lines[0], size=6.5, bold=True, color=green)
|
||||
canvas.text(x + 10, y + 31, label_lines[1], size=6.5, bold=True, color=green)
|
||||
canvas.text(
|
||||
x + 10,
|
||||
y + 10,
|
||||
display,
|
||||
size=13.5,
|
||||
bold=True,
|
||||
color=dark,
|
||||
)
|
||||
y -= 26
|
||||
|
||||
if report["roll_weight"]:
|
||||
canvas.text(margin, y, "CALCULATED ROLL WEIGHT", size=7.5, bold=True, color=green)
|
||||
canvas.text(
|
||||
margin + 146,
|
||||
y - 2,
|
||||
report["roll_weight"]["display"],
|
||||
size=12,
|
||||
bold=True,
|
||||
color=dark,
|
||||
)
|
||||
y -= 28
|
||||
|
||||
canvas.line(margin, y, A4_WIDTH - margin, y, color=(0.72, 0.78, 0.74))
|
||||
y -= 19
|
||||
canvas.text(margin, y, "INPUT DATA", size=8, bold=True, color=green)
|
||||
y -= 18
|
||||
column_width = (usable_width - 24) / 2
|
||||
row_height = 23
|
||||
inputs = report["inputs"]
|
||||
rows = math.ceil(len(inputs) / 2)
|
||||
for index, item in enumerate(inputs):
|
||||
column = index % 2
|
||||
row = index // 2
|
||||
x = margin + column * (column_width + 24)
|
||||
item_y = y - row * row_height
|
||||
canvas.text(x, item_y, item["label"].upper(), size=6.8, bold=True, color=muted)
|
||||
value = item["value"]
|
||||
if len(value) > 48:
|
||||
value = value[:47] + "…"
|
||||
canvas.text(x, item_y - 11, value, size=9.2, color=dark)
|
||||
y -= rows * row_height + 9
|
||||
|
||||
entries = [
|
||||
(item["title"], item["message"], item["severity"])
|
||||
for item in report["warnings"]
|
||||
]
|
||||
entries.extend(("Note", note, "note") for note in report["notes"])
|
||||
if entries:
|
||||
canvas.line(margin, y, A4_WIDTH - margin, y, color=(0.72, 0.78, 0.74))
|
||||
y -= 19
|
||||
canvas.text(margin, y, "WARNINGS & NOTES", size=8, bold=True, color=green)
|
||||
y -= 17
|
||||
available = max(72, y - 63)
|
||||
font_size = 8.2
|
||||
prepared = []
|
||||
while True:
|
||||
prepared = []
|
||||
total_lines = 0
|
||||
for title, message, severity in entries:
|
||||
title_lines = _wrap(title, usable_width - 20, font_size)
|
||||
message_lines = _wrap(message, usable_width - 20, font_size)
|
||||
prepared.append((title_lines, message_lines, severity))
|
||||
total_lines += len(title_lines) + len(message_lines)
|
||||
line_height = font_size + 2.2
|
||||
required = total_lines * line_height + len(entries) * 5
|
||||
if required <= available or font_size <= 5.6:
|
||||
break
|
||||
font_size -= 0.4
|
||||
if required > available:
|
||||
raise ReportValidationError(
|
||||
"warnings and notes do not fit on a one-page report"
|
||||
)
|
||||
line_height = font_size + 2.2
|
||||
for title_lines, message_lines, severity in prepared:
|
||||
if severity == "critical":
|
||||
color = (0.69, 0.21, 0.17)
|
||||
elif severity == "warning":
|
||||
color = (0.61, 0.40, 0.08)
|
||||
else:
|
||||
color = green
|
||||
for line in title_lines:
|
||||
canvas.text(margin + 8, y, line, size=font_size, bold=True, color=color)
|
||||
y -= line_height
|
||||
for line in message_lines:
|
||||
canvas.text(margin + 8, y, line, size=font_size, color=dark)
|
||||
y -= line_height
|
||||
y -= 5
|
||||
|
||||
footer_y = 34
|
||||
canvas.line(margin, footer_y + 21, A4_WIDTH - margin, footer_y + 21, color=(0.72, 0.78, 0.74))
|
||||
canvas.text(
|
||||
margin,
|
||||
footer_y + 7,
|
||||
(
|
||||
"Internal advisory calculation. Verify all inputs and outputs for "
|
||||
"plausibility before use."
|
||||
),
|
||||
size=7.2,
|
||||
color=muted,
|
||||
)
|
||||
canvas.text(
|
||||
A4_WIDTH - 160,
|
||||
footer_y + 7,
|
||||
f"Version {build_info.get('version', 'unknown')}",
|
||||
size=7.2,
|
||||
color=muted,
|
||||
)
|
||||
|
||||
# Repeated marks are painted into the page content stream, not attached as annotations/layers.
|
||||
canvas.raw("q /GS1 gs 0.65 0.70 0.67 rg")
|
||||
angle = math.radians(35)
|
||||
a = math.cos(angle)
|
||||
b = math.sin(angle)
|
||||
for x, mark_y in ((-40, 155), (120, 300), (-20, 445), (145, 590), (-15, 735)):
|
||||
canvas.raw(
|
||||
f"BT /F2 29 Tf {a:.5f} {b:.5f} {-b:.5f} {a:.5f} {x:.2f} {mark_y:.2f} Tm "
|
||||
"(INTERNAL USE ONLY) Tj ET"
|
||||
)
|
||||
canvas.raw("Q")
|
||||
return _make_pdf(canvas.stream())
|
||||
@@ -5,3 +5,5 @@ click==8.1.7
|
||||
itsdangerous==2.1.2
|
||||
Jinja2==3.1.2
|
||||
MarkupSafe==2.1.3
|
||||
mcp==1.26.0
|
||||
PyYAML==6.0.2
|
||||
|
||||
+1139
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
"""Local terminal demo for the constrained Ollama RollCalc adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
import json
|
||||
|
||||
from conversation_service import ConversationService
|
||||
from ollama_nlu import OllamaConfig, OllamaNLUClient
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Constrained local natural-language demo for RollCalc",
|
||||
)
|
||||
parser.add_argument("--model", help="Override ROLLCALC_OLLAMA_MODEL")
|
||||
parser.add_argument("--url", help="Override ROLLCALC_OLLAMA_URL")
|
||||
parser.add_argument("--timeout", type=float, help="Ollama timeout in seconds")
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Show raw model JSON, validated interpretation, outcome, and latency",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
config = OllamaConfig.from_env()
|
||||
overrides = {}
|
||||
if args.model:
|
||||
overrides["model"] = args.model
|
||||
if args.url:
|
||||
overrides["base_url"] = args.url
|
||||
if args.timeout is not None:
|
||||
overrides["timeout_seconds"] = args.timeout
|
||||
if overrides:
|
||||
config = replace(config, **overrides)
|
||||
|
||||
service = ConversationService(OllamaNLUClient(config))
|
||||
conversation_id = service.create_conversation()
|
||||
print(
|
||||
f"RollCalc NLU demo ({config.model}). "
|
||||
"Enter 'exit' or 'quit' to stop."
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
message = input("> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return 0
|
||||
if message.casefold() in {"exit", "quit"}:
|
||||
return 0
|
||||
if not message:
|
||||
continue
|
||||
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
message,
|
||||
include_diagnostics=args.debug,
|
||||
)
|
||||
print(response["message"])
|
||||
if args.debug:
|
||||
debug = {
|
||||
"raw_model_json": response.get("nlu", {}).get("raw_model_json"),
|
||||
"validated_interpretation": response.get("interpretation"),
|
||||
"deterministic_outcome": response.get("result", {}).get("status"),
|
||||
"latency_ms": response.get("nlu", {}).get("latency_ms"),
|
||||
}
|
||||
print(json.dumps(debug, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Thin MCP-facing adapters over the shared RollCalc domain services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from machine_constraints import (
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
get_machine,
|
||||
)
|
||||
from roll_calculation import (
|
||||
ArticleRepository,
|
||||
calculate_material_weight,
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
transport_roll_inputs_from_roll_calculation,
|
||||
)
|
||||
from transport_calculation import analyze_transport
|
||||
|
||||
|
||||
def get_article_result(
|
||||
article_number: str | None = None,
|
||||
article_name_hint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate article resolution to the shared article repository."""
|
||||
return get_article(article_number, article_name_hint)
|
||||
|
||||
|
||||
def search_articles_result(query: str) -> dict[str, Any]:
|
||||
"""Delegate article discovery to the shared article repository."""
|
||||
return ArticleRepository.load().search(query)
|
||||
|
||||
|
||||
def get_machine_result(machine: str | None = None) -> dict[str, Any]:
|
||||
"""Delegate configured-machine resolution to the shared repository."""
|
||||
return get_machine(machine)
|
||||
|
||||
|
||||
def check_production_feasibility_result(
|
||||
*,
|
||||
machine: str,
|
||||
roll_weight_kg: float | None = None,
|
||||
average_diameter_mm: float | None = None,
|
||||
maximum_diameter_mm: float | None = None,
|
||||
core_diameter_mm: float | None = None,
|
||||
product_width_m: float | None = None,
|
||||
line_speed_m_per_min: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate configured production feasibility to the shared domain."""
|
||||
return check_production_feasibility({
|
||||
"machine": machine,
|
||||
"roll_weight_kg": roll_weight_kg,
|
||||
"average_diameter_mm": average_diameter_mm,
|
||||
"maximum_diameter_mm": maximum_diameter_mm,
|
||||
"core_diameter_mm": core_diameter_mm,
|
||||
"product_width_m": product_width_m,
|
||||
"line_speed_m_per_min": line_speed_m_per_min,
|
||||
})
|
||||
|
||||
|
||||
def calculate_machine_max_product_length_result(
|
||||
*,
|
||||
machine: str,
|
||||
core_diameter_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, 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,
|
||||
thickness_mm=thickness_mm,
|
||||
thickness_stddev_mm=thickness_stddev_mm,
|
||||
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],
|
||||
*,
|
||||
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(
|
||||
*,
|
||||
roll_length_m: float,
|
||||
width_m: float,
|
||||
area_weight_g_m2: float,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate material-only weight calculation to the shared domain service."""
|
||||
return calculate_material_weight(
|
||||
roll_length_m=roll_length_m,
|
||||
width_m=width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
|
||||
def calculate_product_length_result(
|
||||
*,
|
||||
target_roll_diameter_mm: float,
|
||||
core_diameter_mm: float,
|
||||
thickness_mm: float,
|
||||
thickness_stddev_mm: float | None = None,
|
||||
width_m: float | None = None,
|
||||
area_weight_g_m2: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate target-diameter product-length calculation to the domain."""
|
||||
return calculate_product_length(
|
||||
target_roll_diameter_mm=target_roll_diameter_mm,
|
||||
core_diameter_mm=core_diameter_mm,
|
||||
thickness_mm=thickness_mm,
|
||||
thickness_stddev_mm=thickness_stddev_mm,
|
||||
width_m=width_m,
|
||||
area_weight_g_m2=area_weight_g_m2,
|
||||
)
|
||||
|
||||
|
||||
def calculate_roll_diameter_result(
|
||||
*,
|
||||
article_number: str | None = None,
|
||||
article_name_hint: str | None = None,
|
||||
roll_length_m: float | None = None,
|
||||
width_m: float | None = None,
|
||||
thickness_mm: float | None = None,
|
||||
thickness_stddev_mm: float | None = None,
|
||||
area_weight_g_m2: float | None = None,
|
||||
core_diameter_mm: float | None = None,
|
||||
core_type: str | None = None,
|
||||
category: str | None = None,
|
||||
production_site: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Map MCP inputs to the existing calculation, always requesting weight."""
|
||||
result = calculate_roll({
|
||||
"article_number": article_number,
|
||||
"article_name_hint": article_name_hint,
|
||||
"roll_length_m": roll_length_m,
|
||||
"width_m": width_m,
|
||||
"thickness_mm": thickness_mm,
|
||||
"thickness_stddev_mm": thickness_stddev_mm,
|
||||
"area_weight_g_m2": area_weight_g_m2,
|
||||
"core_diameter_mm": core_diameter_mm,
|
||||
"core_type": core_type,
|
||||
"category": category,
|
||||
"production_site": production_site,
|
||||
"include_roll_weight": True,
|
||||
})
|
||||
if result.get("status") == "success":
|
||||
result = dict(result)
|
||||
result["transport_roll_inputs"] = (
|
||||
transport_roll_inputs_from_roll_calculation(result)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def analyze_transport_capacity_result(
|
||||
*,
|
||||
transport_preset: str,
|
||||
roll_diameter_mm: float,
|
||||
core_diameter_mm: float,
|
||||
roll_width_m: float,
|
||||
roll_weight_kg: float,
|
||||
product_length_m: float,
|
||||
) -> dict[str, Any]:
|
||||
"""Map the preset-only MCP contract to the shared transport domain."""
|
||||
return analyze_transport({
|
||||
"transport_preset": transport_preset,
|
||||
"roll_diameter_mm": roll_diameter_mm,
|
||||
"core_diameter_mm": core_diameter_mm,
|
||||
"roll_width_m": roll_width_m,
|
||||
"roll_weight_kg": roll_weight_kg,
|
||||
"product_length_m": product_length_m,
|
||||
})
|
||||
@@ -3,12 +3,13 @@ set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
VERSION="${1:-v0.4-dev}"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
cat > build_info.json <<EOF
|
||||
{
|
||||
"version": "v0.4-dev",
|
||||
"version": "$VERSION",
|
||||
"branch": "$(git branch --show-current)",
|
||||
"commit": "$(git rev-parse --short HEAD)",
|
||||
"timestamp": "$(date -Iseconds)"
|
||||
|
||||
+329
-147
@@ -43,6 +43,9 @@
|
||||
.btn-calc:hover { background: #1F5438; }
|
||||
.btn-reset { background: #e0e0e0; color: #444; border: none; border-radius: 6px; padding: 11px 20px; font-size: 14px; cursor: pointer; margin-left: 10px; }
|
||||
.btn-reset:hover { background: #d0d0d0; }
|
||||
.btn-pdf { background: #1F5438; color: #fff; border: none; border-radius: 5px; padding: 9px 16px; font-size: 12px; font-weight: 700; cursor: pointer; margin-top: 13px; }
|
||||
.btn-pdf:hover:not(:disabled) { background: #153c28; }
|
||||
.btn-pdf:disabled { cursor: not-allowed; opacity: 0.48; }
|
||||
.result-box { background: linear-gradient(135deg, #D6EBE2 0%, #8CC9AB 100%); border: 2px solid #2BAC70; border-radius: 8px; padding: 16px 20px; margin-bottom: 18px; display: none; }
|
||||
.result-box.show { display: block; }
|
||||
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
|
||||
@@ -469,7 +472,7 @@
|
||||
|
||||
<!-- DIRECT CALCULATION TAB -->
|
||||
<div class="tab-pane active" id="tab-direct">
|
||||
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Product Category</label>
|
||||
@@ -509,10 +512,9 @@
|
||||
<span class="unit-label">mm</span>
|
||||
</div>
|
||||
<div class="core-presets">
|
||||
<button class="core-preset-btn" data-core="133">133mm Stahl</button>
|
||||
<button class="core-preset-btn" data-core="150">150mm PVC</button>
|
||||
<button class="core-preset-btn" data-core="168">168mm PVC</button>
|
||||
<button class="core-preset-btn" data-core="194">194mm Stahl</button>
|
||||
{% for preset in core_presets %}
|
||||
<button class="core-preset-btn" data-core="{{ preset.ui_diameter }}" data-core-material="{{ preset.material }}">{{ preset.label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -613,6 +615,7 @@
|
||||
<div class="result-label" id="d-res-label">Result</div>
|
||||
<div class="result-value" id="d-res-val">–</div>
|
||||
<div class="result-range" id="d-res-range"></div>
|
||||
<button class="btn-pdf" id="d-pdf" type="button" disabled>Download PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="formula-box">
|
||||
@@ -766,11 +769,9 @@
|
||||
<div class="lo-presets-section">
|
||||
<h4>🚚 Preset Transport Type:</h4>
|
||||
<div class="lo-preset-buttons">
|
||||
<button class="lo-preset-btn" data-preset="container_20ft">20ft Container</button>
|
||||
<button class="lo-preset-btn" data-preset="container_40ft">40ft Container</button>
|
||||
<button class="lo-preset-btn" data-preset="container_40ft_hc">40ft High Cube</button>
|
||||
<button class="lo-preset-btn" data-preset="lkw_sattelzug">LKW Sattelzug</button>
|
||||
<button class="lo-preset-btn" data-preset="lkw_tandem">LKW Tandem</button>
|
||||
{% for preset in transport_presets %}
|
||||
<button class="lo-preset-btn" data-preset="{{ preset.key }}" data-length="{{ preset.length_m }}" data-width="{{ preset.width_m }}" data-height="{{ preset.height_m }}" data-max-weight="{{ preset.max_weight_kg }}">{{ preset.name }}</button>
|
||||
{% endfor %}
|
||||
<button class="lo-preset-btn" data-preset="custom">Custom</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -880,6 +881,8 @@
|
||||
// =====================================================
|
||||
window.ARTICLE_DATA = [];
|
||||
window.APP_CONFIG = {"forklift_rules":{"applicable_categories":["bentofix"],"rules":[{"weight_threshold_kg":1700,"max_weight_kg":2750,"requirements":{"min_core_outer_diameter_mm":170},"messages":{"en":{"warning_title":"Heavy Roll – Special Equipment Required","core_ok":"Core diameter OK (≥ 170 mm)","core_nok":"Core diameter too small"}}}],"hard_limit":{"max_weight_kg":2750,"messages":{"en":{"block_title":"Weight Limit Exceeded","block_text":"Roll weight exceeds 2750 kg maximum"}}}}};
|
||||
window.CURRENT_ROLL_REQUEST = null;
|
||||
window.CURRENT_ROLL_RESULT = null;
|
||||
|
||||
// =====================================================
|
||||
// LOAD ARTICLE DATA
|
||||
@@ -936,6 +939,7 @@ function populateArticleDropdowns() {
|
||||
inp.value = val;
|
||||
pane.querySelectorAll('.core-preset-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (pane.id === 'tab-direct') invalidatePdfReport();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -982,7 +986,112 @@ document.querySelectorAll('.mode-btn').forEach(btn => {
|
||||
// =====================================================
|
||||
// DIRECT CALCULATION (WITH 2σ)
|
||||
// =====================================================
|
||||
document.getElementById('d-calc').addEventListener('click', () => {
|
||||
function currentArticle() {
|
||||
const rawValue = document.getElementById('d-art').value.trim();
|
||||
if (!rawValue) return null;
|
||||
const match = (window.ARTICLE_DATA || []).find(article =>
|
||||
rawValue === article.nr || rawValue.startsWith(article.nr + ' –')
|
||||
);
|
||||
if (match) return { number: String(match.nr), name: match.name || '' };
|
||||
const separator = rawValue.indexOf(' – ');
|
||||
if (separator >= 0) {
|
||||
return { number: rawValue.slice(0, separator), name: rawValue.slice(separator + 3) };
|
||||
}
|
||||
return { number: rawValue, name: '' };
|
||||
}
|
||||
|
||||
function currentCoreType(coreDiameter) {
|
||||
const activePreset = document.querySelector('#tab-direct .core-preset-btn.active');
|
||||
if (activePreset && Number(activePreset.dataset.core) === coreDiameter) return activePreset.textContent.trim();
|
||||
return 'Custom / not specified';
|
||||
}
|
||||
|
||||
function invalidatePdfReport() {
|
||||
window.CURRENT_ROLL_REQUEST = null;
|
||||
window.CURRENT_ROLL_RESULT = null;
|
||||
const button = document.getElementById('d-pdf');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Download PDF';
|
||||
}
|
||||
|
||||
function numericInput(id) {
|
||||
const raw = document.getElementById(id).value.trim();
|
||||
return raw === '' ? null : Number(raw);
|
||||
}
|
||||
|
||||
function buildCalculationRequest() {
|
||||
const article = currentArticle();
|
||||
const width = numericInput('d-wi');
|
||||
const areaWeight = numericInput('d-aw');
|
||||
return {
|
||||
intent: 'calculate_roll_diameter',
|
||||
article_number: article?.number || null,
|
||||
article_name_hint: article?.name || null,
|
||||
roll_length_m: numericInput('d-le'),
|
||||
width_m: width,
|
||||
thickness_mm: numericInput('d-th'),
|
||||
thickness_stddev_mm: numericInput('d-tol'),
|
||||
area_weight_g_m2: areaWeight,
|
||||
core_diameter_mm: numericInput('d-co'),
|
||||
core_type: currentCoreType(numericInput('d-co')),
|
||||
category: document.getElementById('d-cat').value || null,
|
||||
production_site: document.getElementById('d-site').value || null,
|
||||
include_roll_weight: width !== null && areaWeight !== null
|
||||
};
|
||||
}
|
||||
|
||||
function calculationIssueMessage(result) {
|
||||
if (result.status === 'article_not_found') return 'Article not found.';
|
||||
if (result.status === 'article_conflict') return 'Article number and name conflict.';
|
||||
if (result.status === 'needs_clarification') {
|
||||
const fields = [...(result.missing || []), ...(result.ambiguous || [])];
|
||||
return 'Please clarify: ' + fields.join(', ');
|
||||
}
|
||||
const issue = result.invalid?.[0]?.message;
|
||||
return issue ? 'Invalid calculation request: ' + issue : 'Calculation failed.';
|
||||
}
|
||||
|
||||
function renderAuthoritativeForkliftResult(result) {
|
||||
const box = document.getElementById('forkliftCheck');
|
||||
const titleEl = document.getElementById('forkliftCheckTitle');
|
||||
const bodyEl = document.getElementById('forkliftCheckBody');
|
||||
const warning = result.warnings?.[0];
|
||||
const note = result.notes?.[0];
|
||||
if (warning) {
|
||||
box.className = 'forklift-check ' + (warning.severity === 'critical' ? 'fc-block' : 'fc-warn') + ' show';
|
||||
titleEl.textContent = '⚠️ ' + warning.title;
|
||||
bodyEl.textContent = warning.message;
|
||||
} else if (note) {
|
||||
box.className = 'forklift-check fc-ok show';
|
||||
titleEl.textContent = '✓ Forklift Check OK';
|
||||
bodyEl.textContent = note;
|
||||
} else {
|
||||
box.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRollCalculation(calculationRequest) {
|
||||
const response = await fetch('/api/calculations/roll', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(calculationRequest)
|
||||
});
|
||||
if (!response.ok) throw new Error('Calculation service failed');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function requestProductLengthCalculation(calculationRequest) {
|
||||
const response = await fetch('/api/calculations/product-length', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(calculationRequest)
|
||||
});
|
||||
if (!response.ok) throw new Error('Product-length calculation service failed');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
document.getElementById('d-calc').addEventListener('click', async () => {
|
||||
invalidatePdfReport();
|
||||
const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter';
|
||||
const d = parseFloat(document.getElementById('d-co').value) || 0;
|
||||
const t = parseFloat(document.getElementById('d-th').value) || 0;
|
||||
@@ -995,27 +1104,77 @@ document.getElementById('d-calc').addEventListener('click', () => {
|
||||
const resLabel = document.getElementById('d-res-label');
|
||||
const resVal = document.getElementById('d-res-val');
|
||||
const resRange = document.getElementById('d-res-range');
|
||||
let diameterResult = null;
|
||||
let authoritativeResult = null;
|
||||
let calculationRequest = null;
|
||||
|
||||
if (mode === 'diameter') {
|
||||
if (!d || !t || !L) { alert('Please enter d, t, and L.'); return; }
|
||||
const D_nom = Math.sqrt(d*d + (4*L*1000*t)/Math.PI);
|
||||
calculationRequest = buildCalculationRequest();
|
||||
try {
|
||||
authoritativeResult = await requestRollCalculation(calculationRequest);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
if (authoritativeResult.status !== 'success') {
|
||||
alert(calculationIssueMessage(authoritativeResult));
|
||||
return;
|
||||
}
|
||||
const calculation = authoritativeResult.calculation;
|
||||
diameterResult = {
|
||||
minimum: calculation.minimum_diameter_mm,
|
||||
average: calculation.average_diameter_mm,
|
||||
maximum: calculation.maximum_diameter_mm,
|
||||
minus2Sigma: calculation.minimum_diameter_mm,
|
||||
plus2Sigma: calculation.maximum_diameter_mm,
|
||||
rollLength: calculation.effective_roll_length_m
|
||||
};
|
||||
resLabel.textContent = 'Roll Diameter (D)';
|
||||
resVal.textContent = D_nom.toFixed(1) + ' mm';
|
||||
resVal.textContent = diameterResult.average.toFixed(1) + ' mm';
|
||||
if (tol) {
|
||||
const D_minus2sigma = Math.sqrt(d*d + (4*L*1000*(t-2*tol))/Math.PI);
|
||||
const D_plus2sigma = Math.sqrt(d*d + (4*L*1000*(t+2*tol))/Math.PI);
|
||||
resRange.textContent = '−2σ: ' + D_minus2sigma.toFixed(1) + ' mm | +2σ: ' + D_plus2sigma.toFixed(1) + ' mm';
|
||||
resRange.textContent = '−2σ: ' + diameterResult.minus2Sigma.toFixed(1) + ' mm | +2σ: ' + diameterResult.plus2Sigma.toFixed(1) + ' mm';
|
||||
} else {
|
||||
resRange.textContent = '';
|
||||
}
|
||||
document.getElementById('d-di').value = D_nom.toFixed(1);
|
||||
document.getElementById('d-di').value = diameterResult.average.toFixed(1);
|
||||
if (authoritativeResult.effective_inputs.width_m?.source === 'article_master_data') {
|
||||
document.getElementById('d-wi').value = authoritativeResult.effective_inputs.width_m.value;
|
||||
}
|
||||
const authoritativeWeight = calculation.roll_weight_kg;
|
||||
document.getElementById('d-wo').value = authoritativeWeight === null ? '' : authoritativeWeight.toFixed(1);
|
||||
renderAuthoritativeForkliftResult(authoritativeResult);
|
||||
} else if (mode === 'length') {
|
||||
if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; }
|
||||
const L_calc = (Math.PI / (4 * t)) * (D*D - d*d) / 1000;
|
||||
try {
|
||||
authoritativeResult = await requestProductLengthCalculation({
|
||||
target_roll_diameter_mm: D,
|
||||
core_diameter_mm: d,
|
||||
thickness_mm: t,
|
||||
thickness_stddev_mm: tol,
|
||||
width_m: numericInput('d-wi'),
|
||||
area_weight_g_m2: numericInput('d-aw')
|
||||
});
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
if (authoritativeResult.status !== 'success') {
|
||||
const issue = authoritativeResult.invalid?.[0]?.message;
|
||||
alert(issue || 'Product-length calculation failed.');
|
||||
return;
|
||||
}
|
||||
const calculation = authoritativeResult.calculation;
|
||||
const L_calc = calculation.average_product_length_m;
|
||||
resLabel.textContent = 'Product Length (L)';
|
||||
resVal.textContent = L_calc.toFixed(2) + ' m';
|
||||
resRange.textContent = '';
|
||||
if (tol) {
|
||||
resRange.textContent = '−2σ: ' + calculation.minimum_product_length_m.toFixed(2) + ' m | +2σ: ' + calculation.maximum_product_length_m.toFixed(2) + ' m';
|
||||
} else {
|
||||
resRange.textContent = '';
|
||||
}
|
||||
document.getElementById('d-le').value = L_calc.toFixed(2);
|
||||
const materialWeight = calculation.material_weights_kg?.average_kg;
|
||||
document.getElementById('d-wo').value = materialWeight === undefined ? '' : materialWeight.toFixed(1);
|
||||
} else if (mode === 'target') {
|
||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||
@@ -1074,17 +1233,24 @@ document.getElementById('d-calc').addEventListener('click', () => {
|
||||
document.getElementById('d-wo').value = W_result.toFixed(1);
|
||||
}
|
||||
|
||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
|
||||
if (wi > 0 && aw > 0 && L_final > 0) {
|
||||
const weight = aw * L_final * wi / 1000;
|
||||
document.getElementById('d-wo').value = weight.toFixed(1);
|
||||
checkForklift(weight, d, category);
|
||||
} else {
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
if (mode !== 'diameter') {
|
||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
|
||||
if (wi > 0 && aw > 0 && L_final > 0) {
|
||||
const rollWeight = aw * L_final * wi / 1000;
|
||||
document.getElementById('d-wo').value = rollWeight.toFixed(1);
|
||||
checkForklift(rollWeight, d, category);
|
||||
} else {
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'diameter' && authoritativeResult) {
|
||||
window.CURRENT_ROLL_REQUEST = calculationRequest;
|
||||
window.CURRENT_ROLL_RESULT = authoritativeResult;
|
||||
document.getElementById('d-pdf').disabled = false;
|
||||
}
|
||||
resultBox.style.display = 'block';
|
||||
});
|
||||
|
||||
@@ -1096,6 +1262,7 @@ document.getElementById('d-reset').addEventListener('click', () => {
|
||||
document.getElementById('d-art').value = '';
|
||||
document.getElementById('d-result').style.display = 'none';
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
invalidatePdfReport();
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
@@ -1141,110 +1308,96 @@ function checkForklift(weightKg, coreMm, category) {
|
||||
const cfg = window.APP_CONFIG?.forklift_rules;
|
||||
if (!cfg || !cfg.applicable_categories.includes(category.toLowerCase())) {
|
||||
box.classList.remove('show');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = cfg.rules[0];
|
||||
const limit = cfg.hard_limit;
|
||||
|
||||
if (weightKg > limit.max_weight_kg) {
|
||||
const message = limit.messages.en.block_text.replace('{weight}', weightKg);
|
||||
box.className = 'forklift-check fc-block show';
|
||||
titleEl.textContent = '⚠️ ' + limit.messages.en.block_title;
|
||||
bodyEl.innerHTML = '<p>' + limit.messages.en.block_text.replace('{weight}', weightKg) + '</p>';
|
||||
return;
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return {
|
||||
kind: 'warning',
|
||||
severity: 'critical',
|
||||
title: limit.messages.en.block_title,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
if (weightKg >= rule.weight_threshold_kg) {
|
||||
const coreOk = coreMm >= rule.requirements.min_core_outer_diameter_mm;
|
||||
const coreMessage = coreOk
|
||||
? 'Core diameter OK'
|
||||
: 'Core diameter ' + coreMm + ' mm (min 170 mm required)';
|
||||
const message = 'Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg. ' + coreMessage + '.';
|
||||
box.className = 'forklift-check fc-warn show';
|
||||
titleEl.textContent = '⚠️ ' + rule.messages.en.warning_title;
|
||||
const statusMsg = coreOk ? '<li class="fc-req-ok">✓ Core diameter OK</li>' : '<li class="fc-req-nok">✗ Core diameter ' + coreMm + ' mm (min 170 mm required)</li>';
|
||||
bodyEl.innerHTML = '<p>Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg:</p><ul>' + statusMsg + '</ul>';
|
||||
return;
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return {
|
||||
kind: 'warning',
|
||||
severity: 'warning',
|
||||
title: rule.messages.en.warning_title,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
const message = 'Roll weight ' + weightKg.toFixed(1) + ' kg is within standard limits. No special equipment required.';
|
||||
box.className = 'forklift-check fc-ok show';
|
||||
titleEl.textContent = '✓ Forklift Check OK';
|
||||
bodyEl.innerHTML = '<p>Roll weight ' + weightKg.toFixed(1) + ' kg is within standard limits. No special equipment required.</p>';
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return { kind: 'note', message };
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// LOAD OPTIMIZER CORE
|
||||
// =====================================================
|
||||
class LoadOptimizer {
|
||||
constructor() {
|
||||
this.state = {
|
||||
rollDiameter: 0, coreDiameter: 150, rollWidth: 0,
|
||||
rollWeight: 0, m2PerRoll: 0, productLength: 0
|
||||
};
|
||||
this.presets = {
|
||||
container_20ft: { name:"20ft Container", length:5.90, width:2.35, height:2.39, maxWeight:21600 },
|
||||
container_40ft: { name:"40ft Container", length:12.19, width:2.35, height:2.39, maxWeight:30480 },
|
||||
container_40ft_hc:{ name:"40ft High Cube", length:12.19, width:2.35, height:2.69, maxWeight:30480 },
|
||||
lkw_sattelzug: { name:"LKW Sattelzug", length:13.50, width:2.50, height:2.70, maxWeight:24000 },
|
||||
lkw_tandem: { name:"LKW Tandem", length:13.50, width:2.50, height:2.70, maxWeight:25000 }
|
||||
};
|
||||
document.getElementById('tab-direct').addEventListener('input', event => {
|
||||
if (event.target.id !== 'd-wo') invalidatePdfReport();
|
||||
});
|
||||
document.getElementById('tab-direct').addEventListener('change', invalidatePdfReport);
|
||||
document.querySelectorAll('.mode-btn').forEach(button => button.addEventListener('click', invalidatePdfReport));
|
||||
|
||||
document.getElementById('d-pdf').addEventListener('click', async () => {
|
||||
const button = document.getElementById('d-pdf');
|
||||
if (!window.CURRENT_ROLL_REQUEST || button.disabled) return;
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Creating PDF…';
|
||||
try {
|
||||
const response = await fetch('/api/reports/roll-calculation.pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(window.CURRENT_ROLL_REQUEST)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || 'PDF generation failed');
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename\*?=(?:UTF-8''|"?)([^";]+)/i);
|
||||
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1].replace(/"$/, '')) : 'roll-calculation.pdf';
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
} finally {
|
||||
button.disabled = !window.CURRENT_ROLL_REQUEST;
|
||||
button.textContent = 'Download PDF';
|
||||
}
|
||||
setRollData(data) {
|
||||
this.state = {
|
||||
rollDiameter: data.rollDiameter || 0,
|
||||
coreDiameter: data.coreDiameter || 150,
|
||||
rollWidth: data.rollWidth || 0,
|
||||
rollWeight: data.rollWeight || 0,
|
||||
m2PerRoll: data.m2PerRoll || 0,
|
||||
productLength: data.productLength || 0
|
||||
};
|
||||
}
|
||||
calculateLoadUtilization(dim, maxLoadWeight, marginSide, marginCeiling) {
|
||||
marginSide = marginSide || 0;
|
||||
marginCeiling = marginCeiling || 0;
|
||||
|
||||
const { rollDiameter, coreDiameter, rollWidth, rollWeight, m2PerRoll } = this.state;
|
||||
const D = rollDiameter / 1000, d = coreDiameter / 1000;
|
||||
|
||||
const effWidth = dim.width - marginSide;
|
||||
const effHeight = dim.height - marginCeiling;
|
||||
|
||||
const rollsAlongLength = Math.floor(dim.length / rollWidth);
|
||||
const rollsAlongWidth = Math.floor(effWidth / D);
|
||||
const rollsPerLayer = rollsAlongLength * rollsAlongWidth;
|
||||
const maxStackHeight = Math.floor(effHeight / D);
|
||||
const maxByGeometry = rollsPerLayer * maxStackHeight;
|
||||
const maxByWeight = rollWeight > 0 ? Math.floor(maxLoadWeight / rollWeight) : Infinity;
|
||||
|
||||
const volPerRoll = (Math.PI / 4) * (D*D - d*d) * rollWidth;
|
||||
const containerVol = dim.length * dim.width * dim.height;
|
||||
const effVol = dim.length * effWidth * effHeight;
|
||||
const maxByVolume = volPerRoll > 0 ? Math.floor(effVol / volPerRoll) : Infinity;
|
||||
|
||||
const scenarios = [
|
||||
{ name:'Weight', rolls: maxByWeight, weight: maxByWeight * rollWeight, type:'weight' },
|
||||
{ name:'Volume', rolls: maxByVolume, weight: maxByVolume * rollWeight, type:'volume' },
|
||||
{ name:'Geometry', rolls: maxByGeometry, weight: maxByGeometry * rollWeight, type:'geometry' }
|
||||
];
|
||||
const limiting = scenarios.reduce((mn, c) => c.rolls < mn.rolls ? c : mn);
|
||||
const finalRolls = limiting.rolls === Infinity ? 0 : limiting.rolls;
|
||||
const finalWeight = finalRolls * rollWeight;
|
||||
const finalM2 = finalRolls * m2PerRoll;
|
||||
|
||||
return {
|
||||
scenarios, limiting, finalRolls, finalWeight, finalM2,
|
||||
utilizationWeight: maxLoadWeight > 0 ? (finalWeight / maxLoadWeight) * 100 : 0,
|
||||
utilizationVolume: containerVol > 0 ? (finalRolls * volPerRoll / containerVol) * 100 : 0,
|
||||
rollsPerLayer, rollsAlongLength, rollsAlongWidth, maxStackHeight,
|
||||
containerVol, volPerRoll, effWidth, effHeight,
|
||||
marginSide, marginCeiling
|
||||
};
|
||||
}
|
||||
fmt(n, dec=1) { return isFinite(n) ? n.toFixed(dec) : '–'; }
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// LOAD OPTIMIZER UI
|
||||
// =====================================================
|
||||
class LoadOptimizerUI {
|
||||
constructor() {
|
||||
this.optimizer = new LoadOptimizer();
|
||||
this.currentPreset = null;
|
||||
this.init();
|
||||
}
|
||||
@@ -1295,12 +1448,12 @@ class LoadOptimizerUI {
|
||||
}
|
||||
loadPreset(id) {
|
||||
if (id === 'custom') { this.clearPresets(); return; }
|
||||
const p = this.optimizer.presets[id];
|
||||
if (!p) return;
|
||||
document.getElementById('customLength').value = p.length;
|
||||
document.getElementById('customWidth').value = p.width;
|
||||
document.getElementById('customHeight').value = p.height;
|
||||
document.getElementById('customMaxWeight').value = p.maxWeight;
|
||||
const button = document.querySelector('[data-preset="'+id+'"]');
|
||||
if (!button) return;
|
||||
document.getElementById('customLength').value = button.dataset.length;
|
||||
document.getElementById('customWidth').value = button.dataset.width;
|
||||
document.getElementById('customHeight').value = button.dataset.height;
|
||||
document.getElementById('customMaxWeight').value = button.dataset.maxWeight;
|
||||
this.updatePresetBtns(id);
|
||||
this.currentPreset = id;
|
||||
this.updateEffectiveHints();
|
||||
@@ -1317,7 +1470,7 @@ class LoadOptimizerUI {
|
||||
return { sideM, ceilM };
|
||||
}
|
||||
|
||||
calculate() {
|
||||
async calculate() {
|
||||
const L = parseFloat(document.getElementById('customLength').value);
|
||||
const W = parseFloat(document.getElementById('customWidth').value);
|
||||
const H = parseFloat(document.getElementById('customHeight').value);
|
||||
@@ -1332,10 +1485,37 @@ class LoadOptimizerUI {
|
||||
return;
|
||||
}
|
||||
const { sideM, ceilM } = this.getMargins();
|
||||
this.optimizer.setRollData(rollData);
|
||||
const res = this.optimizer.calculateLoadUtilization({ length:L, width:W, height:H }, MW, sideM, ceilM);
|
||||
this.showResults(res, MW);
|
||||
this.drawSideView(res, { length:L, width:W, height:H });
|
||||
let response;
|
||||
try {
|
||||
response = await fetch('/api/calculations/transport', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
transport_preset: this.currentPreset,
|
||||
length_m: L,
|
||||
width_m: W,
|
||||
height_m: H,
|
||||
max_weight_kg: MW,
|
||||
margin_side_m: sideM,
|
||||
margin_ceiling_m: ceilM,
|
||||
roll_diameter_mm: rollData.rollDiameter,
|
||||
core_diameter_mm: rollData.coreDiameter,
|
||||
roll_width_m: rollData.rollWidth,
|
||||
roll_weight_kg: rollData.rollWeight,
|
||||
product_length_m: rollData.productLength
|
||||
})
|
||||
});
|
||||
} catch (_error) {
|
||||
alert('Transport calculation service failed');
|
||||
return;
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.status !== 'success') {
|
||||
alert(payload?.invalid?.[0]?.message || 'Transport calculation failed');
|
||||
return;
|
||||
}
|
||||
this.showResults(payload.analysis);
|
||||
this.drawSideView(payload.analysis);
|
||||
}
|
||||
|
||||
getRollData() {
|
||||
@@ -1345,30 +1525,29 @@ class LoadOptimizerUI {
|
||||
const L = parseFloat(document.getElementById('d-le')?.value || 0);
|
||||
const d = parseFloat(document.getElementById('d-co')?.value || 150);
|
||||
if (!D || !W || !GW) return null;
|
||||
return { rollDiameter:D, coreDiameter:d, rollWidth:W, rollWeight:GW, m2PerRoll:L*W, productLength:L };
|
||||
return { rollDiameter:D, coreDiameter:d, rollWidth:W, rollWeight:GW, productLength:L };
|
||||
}
|
||||
|
||||
showResults(res, maxW) {
|
||||
const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
|
||||
document.getElementById('limitingFactorValue').textContent = res.limiting.name;
|
||||
document.getElementById('totalRollsValue').textContent = res.finalRolls;
|
||||
showResults(analysis) {
|
||||
document.getElementById('limitingFactorValue').textContent = analysis.limiting.name;
|
||||
document.getElementById('totalRollsValue').textContent = analysis.final_rolls;
|
||||
document.getElementById('rollsDetailValue').textContent =
|
||||
'(' + res.rollsAlongLength + ' along length × ' + res.rollsAlongWidth + ' across, ' + res.maxStackHeight + ' layers)';
|
||||
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
|
||||
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
|
||||
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
|
||||
'(' + analysis.layout.rolls_along_length + ' along length × ' + analysis.layout.rolls_along_width + ' across, ' + analysis.layout.max_stack_height + ' layers)';
|
||||
document.getElementById('weightUtilValue').textContent = this.fmt(analysis.utilization_weight);
|
||||
document.getElementById('volumeUtilValue').textContent = this.fmt(analysis.utilization_volume);
|
||||
document.getElementById('productM2Value').textContent = this.fmt(analysis.final_m2, 0);
|
||||
const tbody = document.querySelector('#comparisonTable tbody');
|
||||
tbody.innerHTML = '';
|
||||
res.scenarios.forEach(sc => {
|
||||
analysis.scenarios.forEach(sc => {
|
||||
const tr = document.createElement('tr');
|
||||
const lim = (sc.type === res.limiting.type);
|
||||
const lim = (sc.type === analysis.limiting.type);
|
||||
if (lim) tr.classList.add('lo-recommended');
|
||||
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
|
||||
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
|
||||
const util = this.fmt(sc.utilization_weight);
|
||||
const scM2 = sc.m2 === null ? '–' : Math.round(sc.m2);
|
||||
tr.innerHTML =
|
||||
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+
|
||||
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
|
||||
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
|
||||
'<td>'+ (sc.rolls === null ? '–' : sc.rolls) +'</td>'+
|
||||
'<td>'+ (sc.weight === null ? '–' : Math.round(sc.weight)) +'</td>'+
|
||||
'<td>'+ scM2 +'</td>'+
|
||||
'<td>'+ util +'%</td>';
|
||||
tbody.appendChild(tr);
|
||||
@@ -1376,26 +1555,29 @@ class LoadOptimizerUI {
|
||||
document.getElementById('resultsContainer').classList.add('show');
|
||||
}
|
||||
|
||||
drawSideView(res, dim) {
|
||||
fmt(n, dec=1) { return Number.isFinite(n) ? n.toFixed(dec) : '–'; }
|
||||
|
||||
drawSideView(analysis) {
|
||||
const svg = document.getElementById('sideViewSVG');
|
||||
const section = document.getElementById('sideViewSection');
|
||||
if (!svg || !res || res.finalRolls === 0) return;
|
||||
if (!svg || !analysis || analysis.final_rolls === 0) return;
|
||||
|
||||
const D_m = this.optimizer.state.rollDiameter / 1000;
|
||||
const aW = res.rollsAlongWidth;
|
||||
const aL = res.rollsAlongLength;
|
||||
const layers = res.maxStackHeight;
|
||||
const total = res.finalRolls;
|
||||
const marginSide = res.marginSide || 0;
|
||||
const marginCeil = res.marginCeiling || 0;
|
||||
const D_m = analysis.roll.diameter_mm / 1000;
|
||||
const aW = analysis.layout.rolls_along_width;
|
||||
const aL = analysis.layout.rolls_along_length;
|
||||
const layers = analysis.layout.max_stack_height;
|
||||
const total = analysis.final_rolls;
|
||||
const marginSide = analysis.layout.margin_side_m || 0;
|
||||
const marginCeil = analysis.layout.margin_ceiling_m || 0;
|
||||
const dim = analysis.transport;
|
||||
|
||||
const PAD = 52, W_SVG = 800, H_SVG = 300;
|
||||
const scaleX = (W_SVG - 2*PAD) / dim.width;
|
||||
const scaleY = (H_SVG - 2*PAD) / dim.height;
|
||||
const scaleX = (W_SVG - 2*PAD) / dim.width_m;
|
||||
const scaleY = (H_SVG - 2*PAD) / dim.height_m;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
const cW = dim.width * scale;
|
||||
const cH = dim.height * scale;
|
||||
const cW = dim.width_m * scale;
|
||||
const cH = dim.height_m * scale;
|
||||
const cX = PAD + ((W_SVG - 2*PAD) - cW) / 2;
|
||||
const cY = PAD + ((H_SVG - 2*PAD) - cH) / 2;
|
||||
const rD = D_m * scale;
|
||||
@@ -1446,12 +1628,12 @@ class LoadOptimizerUI {
|
||||
|
||||
const dimY = cY + cH + 20;
|
||||
s += '<line x1="'+cX.toFixed(1)+'" y1="'+dimY.toFixed(1)+'" x2="'+(cX+cW).toFixed(1)+'" y2="'+dimY.toFixed(1)+'" stroke="#666" stroke-width="1" marker-start="url(#arr2)" marker-end="url(#arr)"/>';
|
||||
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(dimY+13).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#555">'+dim.width+' m</text>';
|
||||
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(dimY+13).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#555">'+dim.width_m+' m</text>';
|
||||
|
||||
const dimX = cX - 22;
|
||||
const midY = (cY + cH/2).toFixed(1);
|
||||
s += '<line x1="'+dimX.toFixed(1)+'" y1="'+cY.toFixed(1)+'" x2="'+dimX.toFixed(1)+'" y2="'+(cY+cH).toFixed(1)+'" stroke="#666" stroke-width="1" marker-start="url(#arr2)" marker-end="url(#arr)"/>';
|
||||
s += '<text x="'+(dimX-5).toFixed(1)+'" y="'+midY+'" text-anchor="middle" font-size="11" fill="#555" transform="rotate(-90,'+(dimX-5).toFixed(1)+','+midY+')">'+dim.height+' m</text>';
|
||||
s += '<text x="'+(dimX-5).toFixed(1)+'" y="'+midY+'" text-anchor="middle" font-size="11" fill="#555" transform="rotate(-90,'+(dimX-5).toFixed(1)+','+midY+')">'+dim.height_m+' m</text>';
|
||||
|
||||
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(cY-8).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#666" font-style="italic">'+aL+' rows deep (into page)</text>';
|
||||
s += '<text x="'+(cX+cW-4).toFixed(1)+'" y="'+(cY+15).toFixed(1)+'" text-anchor="end" font-size="10" fill="#555" font-style="italic">'+aW+' wide × '+layers+' high × '+aL+' deep · '+total+' rolls</text>';
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
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):
|
||||
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.headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
def tearDown(self):
|
||||
rollcalc_app.LOG_FILE = self.original_log_file
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_calculation_endpoint_returns_machine_readable_authoritative_result(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/roll",
|
||||
json={
|
||||
"article_number": "180205",
|
||||
"roll_length_m": 65,
|
||||
"core_diameter_mm": 150,
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["resolved_article"]["number"], "180205")
|
||||
self.assertEqual(
|
||||
result["provenance"]["calculator"],
|
||||
"roll_calculation.calculate_roll",
|
||||
)
|
||||
|
||||
def test_modification_endpoint_changes_state_and_recalculates(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/roll/modify",
|
||||
json={
|
||||
"state": {
|
||||
"request": {
|
||||
"article_number": "180205",
|
||||
"roll_length_m": 65,
|
||||
"core_diameter_mm": 150,
|
||||
}
|
||||
},
|
||||
"changes": {"roll_length_m": 80},
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0)
|
||||
self.assertEqual(result["calculation"]["effective_roll_length_m"], 80.0)
|
||||
|
||||
def test_product_length_endpoint_uses_the_domain_calculation(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/product-length",
|
||||
json={
|
||||
"target_roll_diameter_mm": 600,
|
||||
"core_diameter_mm": 140,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"width_m": 6.0,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertAlmostEqual(
|
||||
result["calculation"]["average_product_length_m"], 62.0068774341
|
||||
)
|
||||
|
||||
def test_transport_endpoint_delegates_to_shared_domain_service(self):
|
||||
payload = {
|
||||
"length_m": 10,
|
||||
"width_m": 5,
|
||||
"height_m": 5,
|
||||
"max_weight_kg": 10000,
|
||||
"roll_diameter_mm": 1000,
|
||||
"core_diameter_mm": 150,
|
||||
"roll_width_m": 1,
|
||||
"roll_weight_kg": 1000,
|
||||
}
|
||||
with patch.object(
|
||||
rollcalc_app,
|
||||
"analyze_transport",
|
||||
return_value={"status": "success", "analysis": {"final_rolls": 10}},
|
||||
) as analyze:
|
||||
response = self.client.post(
|
||||
"/api/calculations/transport",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.get_json()["analysis"]["final_rolls"], 10)
|
||||
analyze.assert_called_once_with(payload)
|
||||
|
||||
def test_transport_endpoint_returns_structured_domain_validation(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/transport",
|
||||
json={"length_m": 0},
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.get_json()["status"], "invalid_parameter")
|
||||
|
||||
def test_production_feasibility_endpoint_uses_shared_domain(self):
|
||||
response = self.client.post(
|
||||
"/api/calculations/production-feasibility",
|
||||
json={
|
||||
"machine": "B1",
|
||||
"roll_weight_kg": 2500,
|
||||
"average_diameter_mm": 876,
|
||||
"maximum_diameter_mm": 877,
|
||||
"core_diameter_mm": 168,
|
||||
"product_width_m": 5,
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
result = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["feasibility"], "feasible_with_warnings")
|
||||
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=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"],
|
||||
)
|
||||
|
||||
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",
|
||||
json={"machine": "K7", "core_diameter_mm": 194, "thickness_mm": 4},
|
||||
headers=self.headers,
|
||||
)
|
||||
missing = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "core_diameter_mm": 133, "thickness_mm": 4},
|
||||
headers=self.headers,
|
||||
)
|
||||
invalid = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "unexpected": 1},
|
||||
headers=self.headers,
|
||||
)
|
||||
missing_required = self.client.post(
|
||||
"/api/calculations/machine-max-product-length",
|
||||
json={"machine": "K7", "core_diameter_mm": 133},
|
||||
headers=self.headers,
|
||||
)
|
||||
self.assertEqual(incompatible.get_json()["status"], "incompatible")
|
||||
self.assertEqual(missing.get_json()["status"], "needs_clarification")
|
||||
self.assertEqual(invalid.get_json()["status"], "invalid_parameter")
|
||||
self.assertEqual(missing_required.get_json()["status"], "invalid_parameter")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,112 @@
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import app as rollcalc_app
|
||||
from conversation_service import ConversationService, InMemoryConversationStore
|
||||
from ollama_nlu import NLUResponse, OllamaUnavailableError, validate_nlu_payload
|
||||
|
||||
|
||||
class StaticNLUClient:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def interpret(
|
||||
self,
|
||||
message,
|
||||
*,
|
||||
has_state=False,
|
||||
expected_fields=(),
|
||||
current_state=None,
|
||||
):
|
||||
if isinstance(self.payload, Exception):
|
||||
raise self.payload
|
||||
return NLUResponse(
|
||||
interpretation=validate_nlu_payload(self.payload),
|
||||
raw_model_json="raw-json",
|
||||
latency_ms=1.0,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
|
||||
class ConversationApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.original_log_file = rollcalc_app.LOG_FILE
|
||||
self.original_service = rollcalc_app.CONVERSATION_SERVICE
|
||||
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.headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
def tearDown(self):
|
||||
rollcalc_app.LOG_FILE = self.original_log_file
|
||||
rollcalc_app.CONVERSATION_SERVICE = self.original_service
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def configure(self, payload):
|
||||
rollcalc_app.CONVERSATION_SERVICE = ConversationService(
|
||||
StaticNLUClient(payload),
|
||||
store=InMemoryConversationStore(),
|
||||
build_info=rollcalc_app.BUILD_INFO,
|
||||
)
|
||||
|
||||
def create_conversation(self):
|
||||
response = self.client.post("/api/conversations", headers=self.headers)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
return response.get_json()["conversation_id"]
|
||||
|
||||
def test_conversation_success_exposes_authoritative_pdf(self):
|
||||
self.configure({
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": "150mm PVC",
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
})
|
||||
conversation_id = self.create_conversation()
|
||||
response = self.client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"message": "Berechnen"},
|
||||
headers=self.headers,
|
||||
)
|
||||
payload = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(payload["status"], "success")
|
||||
self.assertEqual(
|
||||
payload["result"]["provenance"]["calculator"],
|
||||
"roll_calculation.calculate_roll",
|
||||
)
|
||||
|
||||
pdf = self.client.get(payload["pdf"]["url"], headers=self.headers)
|
||||
self.assertEqual(pdf.status_code, 200)
|
||||
self.assertEqual(pdf.mimetype, "application/pdf")
|
||||
self.assertEqual(pdf.data.count(b"/Type /Page "), 1)
|
||||
calculation = payload["result"]["calculation"]
|
||||
self.assertIn(
|
||||
f"{calculation['average_diameter_mm']:.1f} mm".encode("ascii"),
|
||||
pdf.data,
|
||||
)
|
||||
|
||||
def test_ollama_unavailable_returns_controlled_http_error(self):
|
||||
self.configure(OllamaUnavailableError("offline"))
|
||||
conversation_id = self.create_conversation()
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"message": "Berechnen"},
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.get_json()["status"], "nlu_error")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,852 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from conversation_service import ConversationService, InMemoryConversationStore
|
||||
from ollama_nlu import (
|
||||
NLUResponse,
|
||||
OllamaUnavailableError,
|
||||
NLUValidationError,
|
||||
validate_nlu_payload,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
|
||||
|
||||
def new_interpretation(**changes):
|
||||
payload = {
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": "150mm PVC",
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class QueueNLUClient:
|
||||
def __init__(self, *payloads):
|
||||
self.payloads = list(payloads)
|
||||
self.calls = []
|
||||
|
||||
def interpret(
|
||||
self,
|
||||
message,
|
||||
*,
|
||||
has_state=False,
|
||||
expected_fields=(),
|
||||
current_state=None,
|
||||
):
|
||||
self.calls.append({
|
||||
"message": message,
|
||||
"has_state": has_state,
|
||||
"expected_fields": expected_fields,
|
||||
"current_state": current_state,
|
||||
})
|
||||
payload = self.payloads.pop(0)
|
||||
if isinstance(payload, Exception):
|
||||
raise payload
|
||||
interpretation = validate_nlu_payload(payload)
|
||||
return NLUResponse(
|
||||
interpretation=interpretation,
|
||||
raw_model_json="raw-json",
|
||||
latency_ms=12.34,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
|
||||
class ConversationServiceTests(unittest.TestCase):
|
||||
def create_service(self, *payloads):
|
||||
client = QueueNLUClient(*payloads)
|
||||
service = ConversationService(
|
||||
client,
|
||||
store=InMemoryConversationStore(),
|
||||
build_info={"version": "test"},
|
||||
)
|
||||
return service, client, service.create_conversation()
|
||||
|
||||
def test_valid_new_calculation_calls_the_deterministic_core(self):
|
||||
service, _, conversation_id = self.create_service(new_interpretation())
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
deterministic_calculator.assert_called_once()
|
||||
self.assertEqual(
|
||||
response["result"]["provenance"]["calculator"],
|
||||
"roll_calculation.calculate_roll",
|
||||
)
|
||||
|
||||
def test_follow_up_length_modification_calls_modify_calculation(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "Mach daraus 80 m.")
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["calculation"]["effective_roll_length_m"],
|
||||
80.0,
|
||||
)
|
||||
self.assertTrue(client.calls[1]["has_state"])
|
||||
|
||||
def test_multi_field_follow_up_recalculates_once_and_creates_new_report(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(core_type="194mm Stahl", core_diameter_mm=194.0),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0, "include_roll_weight": True},
|
||||
},
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Ändere bitte die Rollenlänge auf 80m und berechne das Rollengewicht.",
|
||||
)
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(response["result"]["request"]["roll_length_m"], 80.0)
|
||||
self.assertTrue(response["result"]["request"]["include_roll_weight"])
|
||||
self.assertIsNotNone(response["result"]["calculation"]["roll_weight_kg"])
|
||||
self.assertNotEqual(response["pdf"]["report_id"], initial["pdf"]["report_id"])
|
||||
|
||||
def test_multi_field_length_and_width_follow_up(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 65.0, "width_m": 5.0},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
response = service.handle_message(conversation_id, "Nimm 65 m Länge und 5,0 m Breite.")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(response["result"]["request"]["roll_length_m"], 65.0)
|
||||
self.assertEqual(response["result"]["request"]["width_m"], 5.0)
|
||||
|
||||
def test_multi_field_core_preset_and_length_follow_up(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {
|
||||
"core_type": "194-mm-Stahlkern",
|
||||
"roll_length_m": 80.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Nimm den 194-mm-Stahlkern und 80 m Länge.",
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["request"]
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_incomplete_new_weight_output_inherits_current_calculation(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=80.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=None,
|
||||
width_m=5.0,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
include_roll_weight=True,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Wieviel wiegt die Rolle bei 5,0 m Breite?",
|
||||
)
|
||||
|
||||
changes = deterministic_modifier.call_args.args[1]
|
||||
self.assertEqual(
|
||||
changes,
|
||||
{"width_m": 5.0, "include_roll_weight": True},
|
||||
)
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "180205")
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertEqual(request["width_m"], 5.0)
|
||||
self.assertTrue(request["include_roll_weight"])
|
||||
self.assertIsNotNone(response["result"]["calculation"]["roll_weight_kg"])
|
||||
self.assertIn("pdf", response)
|
||||
current_context = client.calls[1]["current_state"]
|
||||
self.assertTrue(current_context["has_successful_calculation"])
|
||||
self.assertEqual(current_context["article_number"], "180205")
|
||||
self.assertEqual(
|
||||
current_context["resolved_article_name"],
|
||||
"Bfix NSP 4900, 5,00 x 40 m",
|
||||
)
|
||||
self.assertEqual(current_context["roll_length_m"], 80.0)
|
||||
self.assertNotIn("calculation", current_context)
|
||||
|
||||
def test_incomplete_new_length_output_inherits_article_and_core(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=65.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "Wie sieht es bei 80 m aus?")
|
||||
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "180205")
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_explicit_new_article_does_not_inherit_current_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=80.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number="146900",
|
||||
article_name_hint=None,
|
||||
roll_length_m=50.0,
|
||||
width_m=None,
|
||||
core_type="150mm PVC",
|
||||
core_diameter_mm=150.0,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung für Artikel 146900.",
|
||||
)
|
||||
|
||||
deterministic_calculator.assert_called_once()
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "146900")
|
||||
self.assertEqual(request["roll_length_m"], 50.0)
|
||||
self.assertEqual(request["core_diameter_mm"], 150.0)
|
||||
|
||||
def test_different_article_in_modify_output_starts_new_calculation(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"article_number": "146900"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung für Artikel 146900.",
|
||||
)
|
||||
|
||||
deterministic_calculator.assert_called_once()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(response["result"]["request"]["article_number"], "146900")
|
||||
self.assertIsNone(response["result"]["request"]["roll_length_m"])
|
||||
self.assertIsNone(response["result"]["request"]["core_diameter_mm"])
|
||||
|
||||
def test_explicit_new_calculation_phrase_does_not_trigger_inheritance(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Erste Berechnung")
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung mit 80 m.",
|
||||
)
|
||||
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertIsNone(response["result"]["request"]["article_number"])
|
||||
self.assertIn("thickness_mm", response["result"]["missing"])
|
||||
|
||||
def test_no_current_state_does_not_inherit_incomplete_request(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
)
|
||||
)
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "Rechne mit 80 m.")
|
||||
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertIn("thickness_mm", response["result"]["missing"])
|
||||
self.assertIn("core_diameter_mm", response["result"]["missing"])
|
||||
|
||||
def test_failed_followup_nlu_does_not_mutate_valid_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
NLUValidationError("invalid follow-up"),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "Ungültige Änderung")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "nlu_error")
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
|
||||
def test_ambiguous_steel_core_uses_deterministic_clarification(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
valid_state = service.conversation_state(conversation_id).calculation_state
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Bitte ändere den Kern auf einen Stahlkern.",
|
||||
)
|
||||
record = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Es sind mehrere Stahlkerne hinterlegt. Welchen Kerndurchmesser "
|
||||
"soll ich verwenden: 133 mm oder 194 mm?",
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["allowed_values"],
|
||||
[133.0, 194.0],
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["core_family"],
|
||||
"steel",
|
||||
)
|
||||
self.assertEqual(record.calculation_state, valid_state)
|
||||
self.assertNotIn(
|
||||
"core_diameter_mm",
|
||||
response["interpretation"]["changes"],
|
||||
)
|
||||
|
||||
def test_ambiguous_pvc_core_lists_all_canonical_diameters(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "plastic core"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "Nimm einen PVC-Kern.")
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Es sind mehrere PVC-Kerne hinterlegt. Welchen Kerndurchmesser "
|
||||
"soll ich verwenden: 150 mm oder 168 mm?",
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["allowed_values"],
|
||||
[150.0, 168.0],
|
||||
)
|
||||
|
||||
def test_valid_ambiguous_core_candidate_resolves_atomically(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
service.handle_message(conversation_id, "Nimm einen Stahlkern.")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "194 mm")
|
||||
|
||||
record = service.conversation_state(conversation_id)
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(len(client.calls), 2)
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertIsNone(record.pending_clarification)
|
||||
self.assertEqual(
|
||||
record.calculation_state["request"]["core_type"],
|
||||
"194mm Stahl",
|
||||
)
|
||||
self.assertEqual(
|
||||
record.calculation_state["request"]["core_diameter_mm"],
|
||||
194.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
response["result"]["effective_inputs"]["core_diameter_mm"]["value"],
|
||||
194.0,
|
||||
)
|
||||
report_request = service.report_request(response["pdf"]["report_id"])
|
||||
self.assertEqual(report_request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(report_request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_ambiguous_multi_change_preserves_patch_until_core_selection(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
pending_response = service.handle_message(
|
||||
conversation_id,
|
||||
"Nimm 80 m und einen Stahlkern.",
|
||||
)
|
||||
pending = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(pending_response["status"], "needs_clarification")
|
||||
self.assertEqual(pending.calculation_state, before.calculation_state)
|
||||
self.assertEqual(
|
||||
pending.pending_clarification.requested_changes,
|
||||
{"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
)
|
||||
self.assertEqual(
|
||||
pending_response["pending_clarification"]["requested_changes"],
|
||||
{"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "194 mm")
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["request"]
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertNotEqual(response["pdf"]["report_id"], initial["pdf"]["report_id"])
|
||||
|
||||
def test_invalid_ambiguous_core_candidate_keeps_valid_state_and_pending(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
valid_record = service.conversation_state(conversation_id)
|
||||
service.handle_message(conversation_id, "Nimm einen Stahlkern.")
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "200 mm")
|
||||
|
||||
record = service.conversation_state(conversation_id)
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(len(client.calls), 2)
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"200 mm ist für einen Stahlkern nicht hinterlegt. "
|
||||
"Verfügbar sind 133 mm und 194 mm.",
|
||||
)
|
||||
self.assertNotIn("pdf", response)
|
||||
self.assertEqual(record.calculation_state, valid_record.calculation_state)
|
||||
self.assertEqual(record.last_result, valid_record.last_result)
|
||||
self.assertEqual(record.pending_fields, ("core_diameter_mm",))
|
||||
self.assertEqual(
|
||||
record.pending_clarification.allowed_values,
|
||||
(133.0, 194.0),
|
||||
)
|
||||
|
||||
def test_explicit_steel_preset_alias_recalculates_and_updates_pdf_request(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "194 mm steel core"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Bitte ändere den Kern auf 194 mm steel core.",
|
||||
)
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertEqual(
|
||||
response["result"]["effective_inputs"]["core_type"]["value"],
|
||||
"194mm Stahl",
|
||||
)
|
||||
report_request = service.report_request(response["pdf"]["report_id"])
|
||||
self.assertEqual(report_request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(report_request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_missing_width_is_filled_by_field_locked_clarification(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number="6001316",
|
||||
article_name_hint=None,
|
||||
include_roll_weight=True,
|
||||
),
|
||||
)
|
||||
clarification = service.handle_message(conversation_id, "Mit Gewicht")
|
||||
|
||||
self.assertEqual(clarification["status"], "needs_clarification")
|
||||
self.assertIn("Welche Breite", clarification["message"])
|
||||
self.assertIsNone(clarification["interpretation"]["width_m"])
|
||||
|
||||
response = service.handle_message(conversation_id, "4,9 m")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "parsed", "field": "width_m", "value": 4.9},
|
||||
)
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
|
||||
def test_pending_core_diameter_accepts_mm_reply_without_calling_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "150 mm")
|
||||
|
||||
self.assertEqual(initial["status"], "needs_clarification")
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["state"]["request"]["core_diameter_mm"],
|
||||
150.0,
|
||||
)
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
|
||||
def test_pending_core_diameter_accepts_unitless_reply(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "150")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["state"]["request"]["core_diameter_mm"],
|
||||
150.0,
|
||||
)
|
||||
|
||||
def test_incompatible_core_reply_keeps_state_result_and_pending_field(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "4,90 m")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["clarification"],
|
||||
{"status": "incompatible", "field": "core_diameter_mm"},
|
||||
)
|
||||
self.assertIn("Millimetern", response["message"])
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
self.assertEqual(after.pending_fields, ("core_diameter_mm",))
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
modifier.assert_not_called()
|
||||
|
||||
def test_pending_roll_length_accepts_meter_reply(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(roll_length_m=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "80 m")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["calculation"]["effective_roll_length_m"],
|
||||
80.0,
|
||||
)
|
||||
|
||||
def test_article_conflict_remains_deterministic(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(article_name_hint="Stex R 1801")
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Konflikt")
|
||||
|
||||
self.assertEqual(response["status"], "article_conflict")
|
||||
self.assertIn("passen nicht zusammen", response["message"])
|
||||
|
||||
def test_ambiguous_article_lists_repository_candidates_with_dimensions(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(response["field"], "article")
|
||||
self.assertEqual(response["reason"], "ambiguous_article")
|
||||
self.assertEqual(
|
||||
response["candidates"],
|
||||
response["pending_clarification"]["candidates"],
|
||||
)
|
||||
self.assertEqual(response["pending_clarification"], {
|
||||
"field": "article",
|
||||
"reason": "ambiguous_article",
|
||||
"candidates": [
|
||||
{
|
||||
"article_number": "146900",
|
||||
"name": "Stex R 1801, 5,80 x 50 m",
|
||||
},
|
||||
{
|
||||
"article_number": "146910",
|
||||
"name": "Stex R 1801, 6,00 x 50 m",
|
||||
},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Ich habe mehrere passende Artikel gefunden. Welchen meinst Du:\n"
|
||||
"146900 – Stex R 1801, 5,80 x 50 m\n"
|
||||
"oder\n"
|
||||
"146910 – Stex R 1801, 6,00 x 50 m?",
|
||||
)
|
||||
|
||||
def test_product_alias_clarification_uses_repository_candidates_only(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Secutex R501",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Welchen Durchmesser hat eine Secutex R501 mit 100m Länge?",
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
candidates = response["pending_clarification"]["candidates"]
|
||||
self.assertIn(
|
||||
{
|
||||
"article_number": "214507",
|
||||
"name": "Stex R 501 (Betonvlies), 5,20 x 100 m",
|
||||
},
|
||||
candidates,
|
||||
)
|
||||
self.assertTrue(
|
||||
all(candidate["article_number"].isdigit() for candidate in candidates)
|
||||
)
|
||||
|
||||
def test_article_candidate_number_followup_skips_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
response = service.handle_message(conversation_id, "146900")
|
||||
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "parsed", "field": "article", "value": "146900"},
|
||||
)
|
||||
self.assertEqual(response["result"]["resolved_article"]["number"], "146900")
|
||||
self.assertIsNone(
|
||||
service.conversation_state(conversation_id).pending_clarification
|
||||
)
|
||||
|
||||
def test_invalid_article_candidate_keeps_pending_state_without_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "999999")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "incompatible", "field": "article"},
|
||||
)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_unresolved_article_ambiguity_does_not_mutate_valid_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"article_name_hint": "Stex R 1801"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "Nimm Stex R 1801")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
self.assertEqual(after.pending_fields, ("article",))
|
||||
|
||||
def test_unknown_article_name_has_deterministic_not_found_message(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Nicht vorhandenes Produkt XYZ",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
self.assertEqual(response["status"], "article_not_found")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Der angegebene Artikel wurde nicht gefunden.",
|
||||
)
|
||||
|
||||
def test_numeric_message_values_come_from_calculation_result(self):
|
||||
service, _, conversation_id = self.create_service(new_interpretation())
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
calculation = response["result"]["calculation"]
|
||||
|
||||
self.assertIn(
|
||||
f"Minimum: {calculation['minimum_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
self.assertIn(
|
||||
f"Mittelwert: {calculation['average_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
self.assertIn(
|
||||
f"Maximum: {calculation['maximum_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
|
||||
def test_ollama_failure_is_controlled_and_does_not_mutate_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
OllamaUnavailableError("offline")
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
record = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "nlu_error")
|
||||
self.assertEqual(response["error"], "OllamaUnavailableError")
|
||||
self.assertIsNone(record.calculation_state)
|
||||
self.assertIsNone(record.last_result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import app as rollcalc_app
|
||||
from core_presets import CORE_PRESETS, matching_core_presets
|
||||
|
||||
|
||||
class CorePresetTests(unittest.TestCase):
|
||||
def test_rendered_web_ui_preset_list_is_unchanged(self):
|
||||
expected = [
|
||||
("133", "steel", "133mm Stahl"),
|
||||
("150", "pvc", "150mm PVC"),
|
||||
("168", "pvc", "168mm PVC"),
|
||||
("194", "steel", "194mm Stahl"),
|
||||
]
|
||||
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
log_file = os.path.join(temp_dir, "access.json")
|
||||
with patch.object(rollcalc_app, "LOG_FILE", log_file):
|
||||
response = rollcalc_app.app.test_client().get(
|
||||
"/",
|
||||
headers={"Authorization": f"Basic {credentials}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rendered = re.findall(
|
||||
r'class="core-preset-btn" data-core="([^"]+)" '
|
||||
r'data-core-material="([^"]+)">([^<]+)</button>',
|
||||
response.get_data(as_text=True),
|
||||
)
|
||||
self.assertEqual(rendered, expected)
|
||||
self.assertEqual(
|
||||
[
|
||||
(preset.ui_diameter, preset.material, preset.label)
|
||||
for preset in CORE_PRESETS
|
||||
],
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_explicit_finite_aliases_resolve_one_canonical_preset(self):
|
||||
german = matching_core_presets("194 mm Stahlkern")
|
||||
english = matching_core_presets("194 mm steel core")
|
||||
|
||||
self.assertEqual(german, english)
|
||||
self.assertEqual(len(german), 1)
|
||||
self.assertEqual(german[0].key, "steel_194")
|
||||
|
||||
def test_generic_existing_families_are_genuinely_ambiguous(self):
|
||||
self.assertEqual(
|
||||
[preset.key for preset in matching_core_presets("Stahlkern")],
|
||||
["steel_133", "steel_194"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[preset.key for preset in matching_core_presets("plastic core")],
|
||||
["pvc_150", "pvc_168"],
|
||||
)
|
||||
|
||||
def test_unknown_core_label_does_not_match(self):
|
||||
self.assertEqual(matching_core_presets("cardboard core"), ())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,329 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from machine_constraints import (
|
||||
MACHINE_CONFIG_FILE,
|
||||
MAX_LENGTH_TIE_ABS_TOLERANCE_M,
|
||||
Machine,
|
||||
MachineConfigurationError,
|
||||
MachineConstraints,
|
||||
MachineRepository,
|
||||
NumericRange,
|
||||
_governing_maximum,
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
get_machine,
|
||||
)
|
||||
|
||||
|
||||
def request(**changes):
|
||||
payload = {
|
||||
"machine": "bento_1",
|
||||
"roll_weight_kg": 2500.0,
|
||||
"average_diameter_mm": 800.0,
|
||||
"maximum_diameter_mm": 850.0,
|
||||
"core_diameter_mm": 168.0,
|
||||
"product_width_m": 4.8,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
def machine_repository(
|
||||
*,
|
||||
diameter_maximum=1100.0,
|
||||
diameter_minimum=200.0,
|
||||
weight_maximum=700.0,
|
||||
width_limits=(4.0, 6.1),
|
||||
allowed_cores=(133.0,),
|
||||
):
|
||||
diameter = (
|
||||
NumericRange(minimum=diameter_minimum, maximum=diameter_maximum)
|
||||
if diameter_maximum is not None else None
|
||||
)
|
||||
weight = NumericRange(maximum=weight_maximum) if weight_maximum is not None else None
|
||||
width = (
|
||||
NumericRange(minimum=width_limits[0], maximum=width_limits[1])
|
||||
if width_limits is not None else None
|
||||
)
|
||||
return MachineRepository((Machine(
|
||||
id="test_machine",
|
||||
name="Test Machine",
|
||||
aliases=(),
|
||||
location="Test",
|
||||
constraints=MachineConstraints(
|
||||
roll_weight_kg=weight,
|
||||
roll_diameter_mm=diameter,
|
||||
allowed_core_diameters_mm=allowed_cores,
|
||||
product_width_m=width,
|
||||
line_speed_m_per_min=None,
|
||||
),
|
||||
),))
|
||||
|
||||
|
||||
class MachineRepositoryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = MachineRepository.load()
|
||||
|
||||
def test_yaml_load_represents_all_source_machines_and_bento_transformation(self):
|
||||
self.assertTrue(MACHINE_CONFIG_FILE.exists())
|
||||
self.assertEqual(len(self.repository.machines), 15)
|
||||
self.assertEqual(
|
||||
{machine.name for machine in self.repository.machines},
|
||||
{
|
||||
"Bento 1", "Bento 2", "K1", "K3/4", "K5", "K6", "K7",
|
||||
"E1", "E4", "Gitter 1", "Gitter 2", "Gitter 3", "Gitter 4",
|
||||
"Secudrain 1/2", "Secudrain 3",
|
||||
},
|
||||
)
|
||||
for identifier in ("bento_1", "bento_2"):
|
||||
machine = self.repository.resolve(identifier)["resolved_machine"]
|
||||
self.assertEqual(machine.constraints.roll_weight_kg.maximum, 2500.0)
|
||||
|
||||
def test_blank_source_line_speed_is_explicitly_not_configured(self):
|
||||
machine = self.repository.resolve("Gitter 1")["machine"]
|
||||
self.assertIsNone(machine["constraints"]["line_speed_m_per_min"])
|
||||
|
||||
def test_lookup_uses_ids_names_aliases_and_harmless_normalization(self):
|
||||
for value, matched_by in (
|
||||
("bento_1", "id"), (" BENTO 1 ", "name"), (" b1 ", "alias"),
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
result = self.repository.resolve(value)
|
||||
self.assertEqual(result["status"], "resolved")
|
||||
self.assertEqual(result["matched_by"], matched_by)
|
||||
self.assertEqual(result["machine"]["id"], "bento_1")
|
||||
self.assertEqual(self.repository.resolve("bento") ["status"], "machine_not_found")
|
||||
|
||||
def test_get_machine_returns_public_configured_data(self):
|
||||
result = get_machine("K 3/4", repository=self.repository)
|
||||
self.assertEqual(result["status"], "resolved")
|
||||
self.assertNotIn("resolved_machine", result)
|
||||
self.assertEqual(result["machine"]["id"], "k_3_4")
|
||||
|
||||
def config_error(self, body):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "machines.yaml"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
with self.assertRaises(MachineConfigurationError):
|
||||
MachineRepository.load(path)
|
||||
|
||||
def test_configuration_validation_rejects_malformed_and_unsupported_versions(self):
|
||||
self.config_error("machines: [")
|
||||
self.config_error("schema_version: 2\nmachines: []\n")
|
||||
self.config_error("schema_version: 1\nunknown: true\nmachines: []\n")
|
||||
|
||||
def test_configuration_validation_rejects_invalid_machine_data(self):
|
||||
valid = """
|
||||
schema_version: 1
|
||||
machines:
|
||||
- id: alpha
|
||||
name: Alpha
|
||||
aliases: [A]
|
||||
location: Here
|
||||
constraints:
|
||||
roll_weight_kg: {maximum: 3}
|
||||
roll_diameter_mm: {minimum: 1, maximum: 2}
|
||||
allowed_core_diameters_mm: [150]
|
||||
product_width_m: {minimum: 1, maximum: 2}
|
||||
line_speed_m_per_min: null
|
||||
"""
|
||||
self.config_error(valid.replace("minimum: 1, maximum: 2", "minimum: 2, maximum: 1"))
|
||||
self.config_error(valid.replace("[150]", "[0]"))
|
||||
self.config_error(valid.replace("aliases: [A]", "aliases: bad"))
|
||||
self.config_error(valid.replace("line_speed_m_per_min: null", "unsupported: true"))
|
||||
self.config_error(valid.replace("[150]", "[not-a-number]"))
|
||||
duplicate_row = valid.split(" - id: alpha", 1)[1].replace(
|
||||
"name: Alpha", "name: Beta", 1
|
||||
)
|
||||
duplicate = valid + "\n - id: beta" + duplicate_row
|
||||
self.config_error(duplicate)
|
||||
|
||||
|
||||
class MachineFeasibilityTests(unittest.TestCase):
|
||||
def check(self, **changes):
|
||||
return check_production_feasibility(request(**changes))
|
||||
|
||||
def test_inclusive_weight_and_range_boundaries(self):
|
||||
self.assertEqual(self.check()["feasibility"], "feasible")
|
||||
self.assertEqual(self.check(roll_weight_kg=2500.0001)["feasibility"], "not_feasible")
|
||||
self.assertEqual(self.check(average_diameter_mm=300, maximum_diameter_mm=300)["feasibility"], "feasible")
|
||||
self.assertEqual(self.check(average_diameter_mm=876, maximum_diameter_mm=876)["feasibility"], "feasible")
|
||||
self.assertEqual(self.check(average_diameter_mm=299, maximum_diameter_mm=299)["feasibility"], "not_feasible")
|
||||
|
||||
def test_exact_width_machines_do_not_have_an_implicit_tolerance(self):
|
||||
exact = self.check(machine="e1", product_width_m=7.5, average_diameter_mm=900, maximum_diameter_mm=900)
|
||||
low = self.check(machine="e1", product_width_m=7.4999, average_diameter_mm=900, maximum_diameter_mm=900)
|
||||
high = self.check(machine="e1", product_width_m=7.5001, average_diameter_mm=900, maximum_diameter_mm=900)
|
||||
self.assertEqual(exact["feasibility"], "feasible")
|
||||
self.assertIn("product_width_m", low["failed_constraints"])
|
||||
self.assertIn("product_width_m", high["failed_constraints"])
|
||||
|
||||
def test_configured_core_equivalents_are_exact_membership_only(self):
|
||||
for core in (130, 133, 150, 168, 170):
|
||||
with self.subTest(core=core):
|
||||
self.assertEqual(self.check(machine="k1", roll_weight_kg=800, core_diameter_mm=core)["feasibility"], "feasible")
|
||||
failed = self.check(machine="k1", roll_weight_kg=800, core_diameter_mm=169.999)
|
||||
self.assertIn("allowed_core_diameters_mm", failed["failed_constraints"])
|
||||
|
||||
def test_diameter_distinguishes_pass_warning_and_failure(self):
|
||||
passed = self.check(average_diameter_mm=800, maximum_diameter_mm=850)
|
||||
warning = self.check(average_diameter_mm=876, maximum_diameter_mm=876.1)
|
||||
failed = self.check(average_diameter_mm=876.1, maximum_diameter_mm=900)
|
||||
self.assertEqual(passed["feasibility"], "feasible")
|
||||
self.assertEqual(warning["feasibility"], "feasible_with_warnings")
|
||||
self.assertEqual(warning["warning_constraints"], ["roll_diameter_mm"])
|
||||
self.assertEqual(failed["feasibility"], "not_feasible")
|
||||
self.assertIn("roll_diameter_mm", failed["failed_constraints"])
|
||||
|
||||
def test_failures_and_warnings_are_all_retained(self):
|
||||
result = self.check(roll_weight_kg=3000, average_diameter_mm=800, maximum_diameter_mm=900, core_diameter_mm=151, product_width_m=4.0)
|
||||
self.assertEqual(result["feasibility"], "not_feasible")
|
||||
self.assertEqual(result["warning_constraints"], ["roll_diameter_mm"])
|
||||
self.assertEqual(set(result["failed_constraints"]), {"roll_weight_kg", "allowed_core_diameters_mm", "product_width_m"})
|
||||
|
||||
def test_missing_values_keep_evaluable_checks_but_never_claim_feasible(self):
|
||||
result = self.check(roll_weight_kg=None, maximum_diameter_mm=None, core_diameter_mm=None)
|
||||
self.assertEqual(result["status"], "needs_clarification")
|
||||
self.assertIsNone(result["feasible"])
|
||||
self.assertEqual(set(result["missing_required_inputs"]), {"roll_weight_kg", "maximum_diameter_mm", "core_diameter_mm"})
|
||||
self.assertIn("product_width_m", result["passed_constraints"])
|
||||
|
||||
def test_line_speed_is_not_required_or_defaulted_in_v1(self):
|
||||
result = self.check(machine="gitter_1", roll_weight_kg=400, average_diameter_mm=600, maximum_diameter_mm=600, core_diameter_mm=140, product_width_m=4.0)
|
||||
line_speed = result["checks"][-1]
|
||||
self.assertEqual(result["feasibility"], "feasible")
|
||||
self.assertEqual(line_speed["state"], "not_evaluated")
|
||||
self.assertIsNone(line_speed["limits"])
|
||||
|
||||
def test_invalid_and_unknown_requests_are_structured(self):
|
||||
self.assertEqual(check_production_feasibility({"machine": "B1", "unknown": 1})["status"], "invalid_parameter")
|
||||
self.assertEqual(check_production_feasibility({"machine": "B1", "core_weight_kg": 10})["status"], "invalid_parameter")
|
||||
self.assertEqual(self.check(machine="not a machine")["status"], "machine_not_found")
|
||||
|
||||
|
||||
class MachineMaximumProductLengthTests(unittest.TestCase):
|
||||
def calculate(self, repository=None, **changes):
|
||||
values = {
|
||||
"machine": "test_machine",
|
||||
"core_diameter_mm": 133,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"product_width_m": 6,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
"repository": repository or machine_repository(),
|
||||
}
|
||||
values.update(changes)
|
||||
return calculate_machine_max_product_length(**values)
|
||||
|
||||
def test_characterizes_article_218500_on_k7(self):
|
||||
result = calculate_machine_max_product_length(
|
||||
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,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertAlmostEqual(result["limits"]["nominal_diameter_length_m"], 217.1900177448)
|
||||
self.assertAlmostEqual(result["limits"]["conservative_diameter_length_m"], 194.6207328149)
|
||||
self.assertAlmostEqual(result["limits"]["material_weight_length_m"], 235.5019136115)
|
||||
self.assertEqual(result["production_maximums"]["nominal_governing_constraints"], ["roll_diameter_nominal"])
|
||||
self.assertEqual(result["production_maximums"]["conservative_governing_constraints"], ["roll_diameter_conservative"])
|
||||
|
||||
def test_stddev_controls_nominal_and_conservative_diameter_limits(self):
|
||||
zero = self.calculate(thickness_stddev_mm=0)
|
||||
varied = self.calculate()
|
||||
self.assertEqual(
|
||||
zero["limits"]["nominal_diameter_length_m"],
|
||||
zero["limits"]["conservative_diameter_length_m"],
|
||||
)
|
||||
self.assertLess(
|
||||
varied["limits"]["conservative_diameter_length_m"],
|
||||
varied["limits"]["nominal_diameter_length_m"],
|
||||
)
|
||||
invalid = self.calculate(thickness_stddev_mm=3)
|
||||
self.assertEqual(invalid["status"], "invalid_parameter")
|
||||
|
||||
def test_weight_can_govern_one_or_both_production_maximums(self):
|
||||
one = self.calculate(repository=machine_repository(weight_maximum=600))
|
||||
both = self.calculate(repository=machine_repository(weight_maximum=500))
|
||||
self.assertEqual(one["production_maximums"]["nominal_governing_constraints"], ["material_weight"])
|
||||
self.assertEqual(one["production_maximums"]["conservative_governing_constraints"], ["roll_diameter_conservative"])
|
||||
self.assertEqual(both["production_maximums"]["nominal_governing_constraints"], ["material_weight"])
|
||||
self.assertEqual(both["production_maximums"]["conservative_governing_constraints"], ["material_weight"])
|
||||
|
||||
def test_ties_use_documented_tolerance_and_canonical_order(self):
|
||||
value, constraints = _governing_maximum((
|
||||
("roll_diameter_nominal", 100.0),
|
||||
("material_weight", 100.0 + MAX_LENGTH_TIE_ABS_TOLERANCE_M / 2),
|
||||
))
|
||||
self.assertEqual(value, 100.0)
|
||||
self.assertEqual(constraints, ["roll_diameter_nominal", "material_weight"])
|
||||
_, constraints = _governing_maximum((
|
||||
("roll_diameter_nominal", 100.0),
|
||||
("material_weight", 100.0 + MAX_LENGTH_TIE_ABS_TOLERANCE_M * 2),
|
||||
))
|
||||
self.assertEqual(constraints, ["roll_diameter_nominal"])
|
||||
|
||||
def test_core_and_width_incompatibilities_never_claim_a_length(self):
|
||||
core = self.calculate(core_diameter_mm=130)
|
||||
width = self.calculate(product_width_m=3.9)
|
||||
self.assertEqual(core["status"], "incompatible")
|
||||
self.assertEqual(core["production_maximums"], None)
|
||||
self.assertEqual(core["failed_compatibility_constraints"], ["allowed_core_diameters_mm"])
|
||||
self.assertEqual(width["status"], "incompatible")
|
||||
self.assertEqual(width["production_maximums"], None)
|
||||
self.assertEqual(width["failed_compatibility_constraints"], ["product_width_m"])
|
||||
|
||||
def test_width_boundaries_and_missing_inputs_are_structured(self):
|
||||
repository = machine_repository(width_limits=(4, 6))
|
||||
self.assertEqual(self.calculate(repository=repository, product_width_m=4)["status"], "success")
|
||||
self.assertEqual(self.calculate(repository=repository, product_width_m=6)["status"], "success")
|
||||
missing = self.calculate(product_width_m=None, area_weight_g_m2=None)
|
||||
self.assertEqual(missing["status"], "needs_clarification")
|
||||
self.assertEqual(missing["missing_required_inputs"], ["product_width_m", "area_weight_g_m2"])
|
||||
self.assertIsNotNone(missing["partial_limits"])
|
||||
self.assertIsNone(missing["production_maximums"])
|
||||
|
||||
def test_exact_width_machine_is_checked_before_length_calculation(self):
|
||||
values = {
|
||||
"machine": "E1",
|
||||
"core_diameter_mm": 150,
|
||||
"thickness_mm": 4.311611,
|
||||
"product_width_m": 7.5,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
}
|
||||
self.assertEqual(calculate_machine_max_product_length(**values)["status"], "success")
|
||||
values["product_width_m"] = 7.5001
|
||||
result = calculate_machine_max_product_length(**values)
|
||||
self.assertEqual(result["status"], "incompatible")
|
||||
self.assertIsNone(result["production_maximums"])
|
||||
|
||||
def test_unconfigured_limiters_and_minimum_diameter_verification(self):
|
||||
no_weight = self.calculate(repository=machine_repository(weight_maximum=None))
|
||||
self.assertEqual(no_weight["limits"]["material_weight_maximum_state"], "not_configured")
|
||||
no_diameter = self.calculate(repository=machine_repository(diameter_maximum=None))
|
||||
self.assertEqual(no_diameter["limits"]["roll_diameter_maximum_state"], "not_configured")
|
||||
self.assertEqual(
|
||||
no_diameter["production_maximums"]["nominal_governing_constraints"],
|
||||
["material_weight"],
|
||||
)
|
||||
no_limits = self.calculate(repository=machine_repository(diameter_maximum=None, weight_maximum=None, width_limits=None))
|
||||
self.assertEqual(no_limits["status"], "no_maximum_length_constraint")
|
||||
no_limits_without_article_size = self.calculate(
|
||||
repository=machine_repository(
|
||||
diameter_maximum=None, weight_maximum=None, width_limits=None
|
||||
),
|
||||
product_width_m=None,
|
||||
area_weight_g_m2=None,
|
||||
)
|
||||
self.assertEqual(
|
||||
no_limits_without_article_size["status"], "no_maximum_length_constraint"
|
||||
)
|
||||
low_weight = self.calculate(repository=machine_repository(weight_maximum=10, diameter_minimum=800))
|
||||
self.assertEqual(low_weight["status"], "incompatible")
|
||||
self.assertIsNone(low_weight["production_maximums"])
|
||||
@@ -0,0 +1,558 @@
|
||||
import copy
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import mcp_server
|
||||
from roll_calculation import (
|
||||
ArticleRepository,
|
||||
calculate_material_weight,
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
)
|
||||
from rollcalc_mcp_tools import (
|
||||
analyze_transport_capacity_result,
|
||||
calculate_machine_max_product_length_result,
|
||||
check_production_feasibility_result,
|
||||
calculate_material_weight_result,
|
||||
calculate_product_length_result,
|
||||
calculate_roll_diameter_result,
|
||||
get_article_result,
|
||||
get_machine_result,
|
||||
project_machine_max_product_length_result,
|
||||
search_articles_result,
|
||||
)
|
||||
from machine_constraints import (
|
||||
calculate_machine_max_product_length,
|
||||
check_production_feasibility,
|
||||
get_machine,
|
||||
)
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
|
||||
class _FakeFastMCP:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.tools = []
|
||||
|
||||
def tool(self):
|
||||
def register(function):
|
||||
self.tools.append(function)
|
||||
return function
|
||||
return register
|
||||
|
||||
|
||||
class McpAdapterTests(unittest.TestCase):
|
||||
def test_get_article_delegates_to_the_domain_for_success_not_found_and_conflict(self):
|
||||
for arguments in (
|
||||
{"article_number": "146900"},
|
||||
{"article_number": "does-not-exist"},
|
||||
{"article_number": "146900", "article_name_hint": "Other product"},
|
||||
):
|
||||
with self.subTest(arguments=arguments):
|
||||
self.assertEqual(
|
||||
get_article_result(**arguments),
|
||||
get_article(**arguments),
|
||||
)
|
||||
|
||||
def test_search_articles_delegates_to_domain_and_preserves_candidates(self):
|
||||
query = "Bfix NSP 4900, 5,00 x 40 m"
|
||||
|
||||
result = search_articles_result(query)
|
||||
|
||||
self.assertEqual(result, ArticleRepository.load().search(query))
|
||||
self.assertEqual(result["status"], "search_results")
|
||||
self.assertEqual(
|
||||
[candidate["article_number"] for candidate in result["candidates"]],
|
||||
["180205", "8180205", "182815", "206900"],
|
||||
)
|
||||
self.assertEqual(
|
||||
result["candidates"][1]["production_site"], "Malaysia"
|
||||
)
|
||||
|
||||
def test_search_articles_preserves_ambiguous_and_not_found_domain_results(self):
|
||||
ambiguous = search_articles_result("Bentofix NSP 4900")
|
||||
missing = search_articles_result("not a RollCalc product")
|
||||
|
||||
self.assertGreater(ambiguous["total_matches"], 1)
|
||||
self.assertGreater(len(ambiguous["candidates"]), 1)
|
||||
self.assertEqual(missing, {
|
||||
"status": "search_not_found",
|
||||
"query": "not a RollCalc product",
|
||||
"total_matches": 0,
|
||||
"candidates": [],
|
||||
})
|
||||
|
||||
def test_machine_adapters_delegate_to_the_shared_domain(self):
|
||||
arguments = {
|
||||
"machine": "B1",
|
||||
"roll_weight_kg": 2500.0,
|
||||
"average_diameter_mm": 876.0,
|
||||
"maximum_diameter_mm": 876.1,
|
||||
"core_diameter_mm": 168.0,
|
||||
"product_width_m": 5.0,
|
||||
}
|
||||
self.assertEqual(get_machine_result("B1"), get_machine("B1"))
|
||||
self.assertEqual(
|
||||
check_production_feasibility_result(**arguments),
|
||||
check_production_feasibility(arguments),
|
||||
)
|
||||
self.assertEqual(
|
||||
check_production_feasibility_result(**arguments)["feasibility"],
|
||||
"feasible_with_warnings",
|
||||
)
|
||||
|
||||
def test_machine_maximum_length_adapter_projects_success_without_mutating_domain(self):
|
||||
arguments = {
|
||||
"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,
|
||||
}
|
||||
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)
|
||||
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):
|
||||
arguments = {
|
||||
"roll_length_m": 60.0,
|
||||
"width_m": 5.0,
|
||||
"area_weight_g_m2": 4850.079676,
|
||||
}
|
||||
|
||||
result = calculate_material_weight_result(**arguments)
|
||||
|
||||
self.assertEqual(result, calculate_material_weight(**arguments))
|
||||
self.assertEqual(result["weight_scope"], "material_only")
|
||||
self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028)
|
||||
|
||||
def test_product_length_adapter_delegates_with_article_218500_values(self):
|
||||
arguments = {
|
||||
"target_roll_diameter_mm": 600.0,
|
||||
"core_diameter_mm": 140.0,
|
||||
"thickness_mm": 4.311611,
|
||||
"thickness_stddev_mm": 0.249999,
|
||||
"width_m": 6.0,
|
||||
"area_weight_g_m2": 495.395833,
|
||||
}
|
||||
|
||||
result = calculate_product_length_result(**arguments)
|
||||
|
||||
self.assertEqual(result, calculate_product_length(**arguments))
|
||||
self.assertAlmostEqual(
|
||||
result["calculation"]["average_product_length_m"], 62.0068774341
|
||||
)
|
||||
|
||||
def test_calculate_roll_diameter_delegates_for_normal_weighted_and_invalid_cases(self):
|
||||
for arguments in (
|
||||
{
|
||||
"article_number": "146900",
|
||||
"roll_length_m": 50.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
},
|
||||
{
|
||||
"article_number": "146900",
|
||||
"roll_length_m": 50.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
"width_m": 5.8,
|
||||
"area_weight_g_m2": 1767.2,
|
||||
},
|
||||
{"roll_length_m": -1.0, "core_diameter_mm": 150.0, "thickness_mm": 2.0},
|
||||
):
|
||||
with self.subTest(arguments=arguments):
|
||||
actual = calculate_roll_diameter_result(**arguments)
|
||||
expected = calculate_roll({**arguments, "include_roll_weight": True})
|
||||
if expected["status"] == "success":
|
||||
self.assertEqual(
|
||||
actual["transport_roll_inputs"]["roll_diameter_mm"],
|
||||
845.9,
|
||||
)
|
||||
del actual["transport_roll_inputs"]
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_roll_calculation_adapter_always_requests_weight_from_the_domain(self):
|
||||
result = calculate_roll_diameter_result(
|
||||
article_number="114030",
|
||||
roll_length_m=15.0,
|
||||
core_diameter_mm=100.0,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertIsNotNone(result["calculation"]["roll_weight_kg"])
|
||||
|
||||
def test_roll_calculation_adapter_uses_shared_transport_input_helper(self):
|
||||
expected_inputs = {"roll_diameter_mm": 123.4}
|
||||
with patch(
|
||||
"rollcalc_mcp_tools.transport_roll_inputs_from_roll_calculation",
|
||||
return_value=expected_inputs,
|
||||
) as helper:
|
||||
result = calculate_roll_diameter_result(
|
||||
article_number="114030",
|
||||
roll_length_m=15.0,
|
||||
core_diameter_mm=100.0,
|
||||
)
|
||||
|
||||
helper.assert_called_once()
|
||||
self.assertEqual(result["transport_roll_inputs"], expected_inputs)
|
||||
|
||||
def test_transport_tool_delegates_to_domain_with_lkw_sattelzug_preset(self):
|
||||
arguments = {
|
||||
"transport_preset": "lkw_sattelzug",
|
||||
"roll_diameter_mm": 1000.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
"roll_width_m": 2.0,
|
||||
"roll_weight_kg": 1000.0,
|
||||
"product_length_m": 50.0,
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
analyze_transport_capacity_result(**arguments),
|
||||
analyze_transport(arguments),
|
||||
)
|
||||
result = analyze_transport_capacity_result(**arguments)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["analysis"]["transport_preset"], "lkw_sattelzug")
|
||||
self.assertEqual(result["analysis"]["final_rolls"], 24)
|
||||
self.assertEqual(result["analysis"]["limiting"]["name"], "Weight")
|
||||
|
||||
def test_server_registers_the_existing_tools_and_article_search(self):
|
||||
mcp = types.ModuleType("mcp")
|
||||
server = types.ModuleType("mcp.server")
|
||||
fastmcp = types.ModuleType("mcp.server.fastmcp")
|
||||
fastmcp.FastMCP = _FakeFastMCP
|
||||
with patch.dict(sys.modules, {
|
||||
"mcp": mcp,
|
||||
"mcp.server": server,
|
||||
"mcp.server.fastmcp": fastmcp,
|
||||
}):
|
||||
instance = mcp_server.create_server()
|
||||
|
||||
self.assertEqual(instance.name, "RollCalc")
|
||||
self.assertEqual(
|
||||
[tool.__name__ for tool in instance.tools],
|
||||
[
|
||||
"get_article",
|
||||
"search_articles",
|
||||
"get_machine",
|
||||
"check_production_feasibility",
|
||||
"calculate_machine_max_product_length",
|
||||
"calculate_material_weight",
|
||||
"calculate_product_length",
|
||||
"calculate_roll_diameter",
|
||||
"analyze_transport_capacity",
|
||||
],
|
||||
)
|
||||
|
||||
def test_server_schema_exposes_weight_and_transport_chaining_contract(self):
|
||||
instance = mcp_server.create_server()
|
||||
tools = instance._tool_manager._tools
|
||||
search_tool = tools["search_articles"]
|
||||
machine_tool = tools["get_machine"]
|
||||
feasibility_tool = tools["check_production_feasibility"]
|
||||
maximum_tool = tools["calculate_machine_max_product_length"]
|
||||
material_weight_tool = tools["calculate_material_weight"]
|
||||
product_length_tool = tools["calculate_product_length"]
|
||||
roll_schema = tools["calculate_roll_diameter"].parameters
|
||||
transport_tool = tools["analyze_transport_capacity"]
|
||||
transport_schema = transport_tool.parameters
|
||||
|
||||
self.assertEqual(search_tool.parameters["required"], ["query"])
|
||||
self.assertEqual(machine_tool.parameters["required"], ["machine"])
|
||||
self.assertEqual(
|
||||
feasibility_tool.parameters["required"], ["machine"]
|
||||
)
|
||||
self.assertIn("average_diameter_mm", feasibility_tool.parameters["properties"])
|
||||
self.assertIn("maximum_diameter_mm", feasibility_tool.parameters["properties"])
|
||||
self.assertIn("tool, not the LLM, decides", feasibility_tool.description)
|
||||
self.assertIn("never override failed constraints", feasibility_tool.description)
|
||||
self.assertIn("warnings", feasibility_tool.description)
|
||||
self.assertIn("Missing configured V1 roll inputs", feasibility_tool.description)
|
||||
self.assertEqual(
|
||||
maximum_tool.parameters["required"],
|
||||
["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 (
|
||||
"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",
|
||||
"nominal and conservative/no-warning production maxima",
|
||||
):
|
||||
self.assertIn(text, maximum_tool.description)
|
||||
self.assertEqual(
|
||||
search_tool.parameters["properties"]["query"]["type"], "string"
|
||||
)
|
||||
material_weight_schema = material_weight_tool.parameters
|
||||
self.assertEqual(
|
||||
material_weight_schema["required"],
|
||||
["roll_length_m", "width_m", "area_weight_g_m2"],
|
||||
)
|
||||
self.assertNotIn("core_diameter_mm", material_weight_schema["properties"])
|
||||
product_length_schema = product_length_tool.parameters
|
||||
self.assertEqual(
|
||||
product_length_schema["required"],
|
||||
[
|
||||
"target_roll_diameter_mm",
|
||||
"core_diameter_mm",
|
||||
"thickness_mm",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
product_length_schema["properties"]["target_roll_diameter_mm"]["type"],
|
||||
"number",
|
||||
)
|
||||
|
||||
self.assertNotIn("include_roll_weight", roll_schema["properties"])
|
||||
preset_schema = transport_schema["properties"]["transport_preset"]
|
||||
self.assertEqual(
|
||||
preset_schema["enum"],
|
||||
[preset.key for preset in transport_presets()],
|
||||
)
|
||||
self.assertEqual(
|
||||
transport_schema["required"],
|
||||
[
|
||||
"transport_preset",
|
||||
"roll_diameter_mm",
|
||||
"core_diameter_mm",
|
||||
"roll_width_m",
|
||||
"roll_weight_kg",
|
||||
"product_length_m",
|
||||
],
|
||||
)
|
||||
for field in transport_schema["required"]:
|
||||
if field == "transport_preset":
|
||||
continue
|
||||
self.assertEqual(
|
||||
transport_schema["properties"][field]["type"], "number"
|
||||
)
|
||||
for field in (
|
||||
"length_m",
|
||||
"width_m",
|
||||
"height_m",
|
||||
"max_weight_kg",
|
||||
"margin_side_m",
|
||||
"margin_ceiling_m",
|
||||
):
|
||||
self.assertNotIn(field, transport_schema["properties"])
|
||||
self.assertNotIn(
|
||||
field,
|
||||
inspect.signature(analyze_transport_capacity_result).parameters,
|
||||
)
|
||||
description = transport_tool.description
|
||||
for text in (
|
||||
"Use this tool when the user asks how many rolls fit on a known RollCalc transport type",
|
||||
"do not ask the user for vehicle dimensions or payload",
|
||||
"LKW-Sattelzug -> lkw_sattelzug",
|
||||
"LKW-Tandem -> lkw_tandem",
|
||||
"20ft Container -> container_20ft",
|
||||
"40ft Container -> container_40ft",
|
||||
"40ft High Cube -> container_40ft_hc",
|
||||
'transport_preset=\"lkw_sattelzug\"',
|
||||
"first call get_article and calculate_roll_diameter",
|
||||
"transport_roll_inputs bundle unchanged",
|
||||
"Do not choose among minimum, average, or maximum diameter",
|
||||
"product_length_m",
|
||||
"do not estimate or invent",
|
||||
"stateless tool does not recalculate a roll",
|
||||
):
|
||||
self.assertIn(text, description)
|
||||
|
||||
search_description = search_tool.description
|
||||
for text in (
|
||||
"name, family, designation, or descriptive article text",
|
||||
"Do not guess or invent an article number",
|
||||
"deterministic retrieval candidates from the RollCalc article master data",
|
||||
"not product recommendations",
|
||||
"total_matches is exactly 1",
|
||||
"use that candidate's exact article_number",
|
||||
"do NOT select the first or highest-ranked candidate, do not treat ranking as permission to choose",
|
||||
"ask the user which article number is intended",
|
||||
"do not calculate yet",
|
||||
"no matching RollCalc article was found",
|
||||
"more specific designation or article number",
|
||||
"do not invent an article",
|
||||
"production_site=Malaysia is descriptive metadata only",
|
||||
"does not imply Bentofix, Bento 2, or any production machine",
|
||||
"Ranking/order expresses retrieval relevance only",
|
||||
"get_article -> calculate_roll_diameter -> optionally analyze_transport_capacity",
|
||||
):
|
||||
self.assertIn(text, search_description)
|
||||
|
||||
material_weight_description = material_weight_tool.description
|
||||
for text in (
|
||||
"material-only roll weight",
|
||||
"does not require, accept, infer, or default a core diameter",
|
||||
"never invent a standard core",
|
||||
"excludes any core weight",
|
||||
"cannot provide roll diameter or a core-inclusive total weight",
|
||||
):
|
||||
self.assertIn(text, material_weight_description)
|
||||
|
||||
roll_description = tools["calculate_roll_diameter"].description
|
||||
self.assertIn("requires a known positive core diameter", roll_description)
|
||||
self.assertIn("do not infer or default one", roll_description)
|
||||
self.assertIn("call calculate_material_weight instead", roll_description)
|
||||
|
||||
product_length_description = product_length_tool.description
|
||||
for text in (
|
||||
"required product/roll length for a target outer roll diameter",
|
||||
"do not infer a missing core diameter, thickness, width, area weight, or article",
|
||||
"minimum, average, and maximum length ranges",
|
||||
"material-weight ranges",
|
||||
"use calculate_roll_diameter for the reverse direction",
|
||||
):
|
||||
self.assertIn(text, product_length_description)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,316 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from ollama_nlu import (
|
||||
NLUValidationError,
|
||||
OllamaConfig,
|
||||
OllamaNLUClient,
|
||||
OllamaResponseError,
|
||||
OllamaTimeoutError,
|
||||
OllamaUnavailableError,
|
||||
parse_clarification_reply,
|
||||
parse_nlu_json,
|
||||
validate_nlu_payload,
|
||||
)
|
||||
|
||||
|
||||
def new_calculation_payload(**changes):
|
||||
payload = {
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": None,
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
|
||||
class RecordingOpener:
|
||||
def __init__(self, model_payload):
|
||||
self.model_payload = model_payload
|
||||
self.request_payload = None
|
||||
self.timeout = None
|
||||
|
||||
def __call__(self, request, *, timeout):
|
||||
self.request_payload = json.loads(request.data.decode("utf-8"))
|
||||
self.timeout = timeout
|
||||
response = {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": json.dumps(self.model_payload),
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
return FakeResponse(json.dumps(response).encode("utf-8"))
|
||||
|
||||
|
||||
class NLUValidationTests(unittest.TestCase):
|
||||
def test_core_diameter_clarification_accepts_mm_and_unitless_values(self):
|
||||
with_unit = parse_clarification_reply("150 mm", "core_diameter_mm")
|
||||
unitless = parse_clarification_reply("150", "core_diameter_mm")
|
||||
|
||||
self.assertEqual(
|
||||
with_unit.to_dict(),
|
||||
{"status": "parsed", "field": "core_diameter_mm", "value": 150.0},
|
||||
)
|
||||
self.assertEqual(unitless.to_dict(), with_unit.to_dict())
|
||||
|
||||
def test_core_diameter_clarification_rejects_meter_unit(self):
|
||||
interpretation = parse_clarification_reply("4,90 m", "core_diameter_mm")
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.to_dict(),
|
||||
{"status": "incompatible", "field": "core_diameter_mm"},
|
||||
)
|
||||
|
||||
def test_length_and_width_clarifications_use_expected_meter_field(self):
|
||||
length = parse_clarification_reply("80 m", "roll_length_m")
|
||||
width = parse_clarification_reply("4,90 m", "width_m")
|
||||
|
||||
self.assertEqual(length.value, 80.0)
|
||||
self.assertEqual(length.field, "roll_length_m")
|
||||
self.assertEqual(width.value, 4.9)
|
||||
self.assertEqual(width.field, "width_m")
|
||||
|
||||
def test_empty_optional_article_name_is_normalized_to_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint="")
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_whitespace_optional_article_name_is_normalized_to_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint=" ")
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_null_optional_article_name_remains_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint=None)
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_valid_optional_article_name_is_preserved(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint="Bentofix NSP 4900")
|
||||
)
|
||||
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
|
||||
def test_empty_text_is_still_invalid_for_a_selected_modification(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": " "},
|
||||
}
|
||||
)
|
||||
|
||||
def test_new_calculation_extracts_requested_german_fields(self):
|
||||
interpretation = validate_nlu_payload(new_calculation_payload())
|
||||
|
||||
self.assertEqual(interpretation.intent, "new_calculation")
|
||||
self.assertEqual(interpretation.article_number, "180205")
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
self.assertEqual(interpretation.roll_length_m, 65.0)
|
||||
self.assertIsNone(interpretation.width_m)
|
||||
self.assertIsNone(interpretation.core_diameter_mm)
|
||||
|
||||
def test_length_modification_contains_only_the_requested_change(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.to_dict(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
},
|
||||
)
|
||||
|
||||
def test_steel_core_modification_never_contains_a_guessed_diameter(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "steel"},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(interpretation.changes, {"core_type": "steel"})
|
||||
self.assertNotIn("core_diameter_mm", interpretation.changes)
|
||||
|
||||
def test_modification_accepts_multiple_explicit_allowed_changes(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {
|
||||
"roll_length_m": 80.0,
|
||||
"width_m": 4.9,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.changes,
|
||||
{"roll_length_m": 80.0, "width_m": 4.9},
|
||||
)
|
||||
|
||||
def test_forbidden_calculated_fields_are_rejected_at_every_level(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
new_calculation_payload(average_diameter_mm=999.0)
|
||||
)
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_weight_kg": 1.0},
|
||||
}
|
||||
)
|
||||
|
||||
def test_invalid_json_is_handled_without_fallback_parsing(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
parse_nlu_json("```json\n{}\n```")
|
||||
|
||||
|
||||
class OllamaClientTests(unittest.TestCase):
|
||||
def test_model_uses_available_default_and_environment_override(self):
|
||||
self.assertEqual(OllamaConfig().model, "qwen3.5:35B-A3B")
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ROLLCALC_OLLAMA_MODEL": "custom-qwen:model"},
|
||||
):
|
||||
self.assertEqual(
|
||||
OllamaConfig.from_env().model,
|
||||
"custom-qwen:model",
|
||||
)
|
||||
|
||||
def test_german_request_uses_constrained_non_thinking_chat_request(self):
|
||||
opener = RecordingOpener(new_calculation_payload())
|
||||
config = OllamaConfig(
|
||||
base_url="http://ollama.test:11434",
|
||||
model="custom-qwen:model",
|
||||
timeout_seconds=12,
|
||||
)
|
||||
client = OllamaNLUClient(config, opener=opener)
|
||||
|
||||
response = client.interpret(
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?"
|
||||
)
|
||||
|
||||
self.assertEqual(response.interpretation.article_number, "180205")
|
||||
self.assertEqual(response.interpretation.roll_length_m, 65.0)
|
||||
self.assertEqual(
|
||||
opener.request_payload["messages"][1]["content"],
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?",
|
||||
)
|
||||
self.assertEqual(opener.request_payload["model"], "custom-qwen:model")
|
||||
self.assertEqual(opener.request_payload["options"]["temperature"], 0.0)
|
||||
self.assertFalse(opener.request_payload["think"])
|
||||
self.assertFalse(opener.request_payload["stream"])
|
||||
self.assertIsInstance(opener.request_payload["format"], dict)
|
||||
self.assertIn(
|
||||
"never return an empty or whitespace-only string",
|
||||
opener.request_payload["messages"][0]["content"],
|
||||
)
|
||||
self.assertEqual(opener.timeout, 12)
|
||||
|
||||
def test_generated_ollama_schema_has_no_regex_patterns(self):
|
||||
opener = RecordingOpener(new_calculation_payload())
|
||||
|
||||
OllamaNLUClient(opener=opener).interpret("Artikel 180205 mit 65 Metern")
|
||||
|
||||
encoded_schema = json.dumps(opener.request_payload["format"])
|
||||
self.assertNotIn('"pattern"', encoded_schema)
|
||||
|
||||
def test_current_input_state_is_provided_without_calculated_results(self):
|
||||
opener = RecordingOpener(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"width_m": 5.0},
|
||||
}
|
||||
)
|
||||
current_state = {
|
||||
"has_successful_calculation": True,
|
||||
"article_number": "180205",
|
||||
"roll_length_m": 80.0,
|
||||
"width_m": None,
|
||||
"core_type": "194mm Stahl",
|
||||
"core_diameter_mm": 194.0,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
|
||||
OllamaNLUClient(opener=opener).interpret(
|
||||
"Wieviel wiegt die Rolle bei 5 m Breite?",
|
||||
has_state=True,
|
||||
current_state=current_state,
|
||||
)
|
||||
|
||||
system_prompt = opener.request_payload["messages"][0]["content"]
|
||||
self.assertIn('"current_calculation":{', system_prompt)
|
||||
self.assertIn('"article_number":"180205"', system_prompt)
|
||||
self.assertIn('"roll_length_m":80.0', system_prompt)
|
||||
self.assertIn("die Rolle, dieselbe Rolle", system_prompt)
|
||||
self.assertNotIn("average_diameter_mm", system_prompt)
|
||||
self.assertNotIn("roll_weight_kg", system_prompt)
|
||||
|
||||
def test_timeout_and_unavailable_service_are_controlled(self):
|
||||
def timeout_opener(request, *, timeout):
|
||||
raise socket.timeout()
|
||||
|
||||
def unavailable_opener(request, *, timeout):
|
||||
raise URLError(ConnectionRefusedError())
|
||||
|
||||
with self.assertRaises(OllamaTimeoutError):
|
||||
OllamaNLUClient(opener=timeout_opener).interpret("Test")
|
||||
with self.assertRaises(OllamaUnavailableError):
|
||||
OllamaNLUClient(opener=unavailable_opener).interpret("Test")
|
||||
|
||||
def test_http_error_includes_status_and_ollama_response_detail(self):
|
||||
def bad_request_opener(request, *, timeout):
|
||||
raise HTTPError(
|
||||
request.full_url,
|
||||
400,
|
||||
"Bad Request",
|
||||
hdrs=None,
|
||||
fp=io.BytesIO(
|
||||
b'{"error":"some Ollama explanation"}'
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaises(OllamaResponseError) as raised:
|
||||
OllamaNLUClient(opener=bad_request_opener).interpret("Test")
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 400)
|
||||
self.assertIn("HTTP 400", str(raised.exception))
|
||||
self.assertIn("some Ollama explanation", str(raised.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from ollama_nlu import OllamaNLUClient
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.getenv("ROLLCALC_RUN_OLLAMA_SMOKE") == "1",
|
||||
"set ROLLCALC_RUN_OLLAMA_SMOKE=1 to use the local Ollama model",
|
||||
)
|
||||
class RealOllamaSmokeTests(unittest.TestCase):
|
||||
def test_representative_german_request(self):
|
||||
response = OllamaNLUClient().interpret(
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?"
|
||||
)
|
||||
|
||||
interpretation = response.interpretation
|
||||
self.assertEqual(interpretation.intent, "new_calculation")
|
||||
self.assertEqual(interpretation.article_number, "180205")
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
self.assertEqual(interpretation.roll_length_m, 65.0)
|
||||
self.assertIsNone(interpretation.width_m)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,289 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class _BaseModel:
|
||||
def __init__(self, **values):
|
||||
for name in self.__class__.__annotations__:
|
||||
setattr(self, name, values.get(name, getattr(self.__class__, name)))
|
||||
|
||||
|
||||
class _SecretStr:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get_secret_value(self):
|
||||
return self._value
|
||||
|
||||
|
||||
def _field(*, default, **_kwargs):
|
||||
return default
|
||||
|
||||
|
||||
class _RequestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _TimeoutException(_RequestError):
|
||||
pass
|
||||
|
||||
|
||||
class _BasicAuth:
|
||||
def __init__(self, username, password):
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
|
||||
def _load_pipe_module():
|
||||
pydantic = types.ModuleType("pydantic")
|
||||
pydantic.BaseModel = _BaseModel
|
||||
pydantic.Field = _field
|
||||
pydantic.SecretStr = _SecretStr
|
||||
httpx = types.ModuleType("httpx")
|
||||
httpx.AsyncClient = object
|
||||
httpx.BasicAuth = _BasicAuth
|
||||
httpx.RequestError = _RequestError
|
||||
httpx.TimeoutException = _TimeoutException
|
||||
module_path = (
|
||||
Path(__file__).parents[1]
|
||||
/ "integrations"
|
||||
/ "openwebui"
|
||||
/ "rollcalc_pipe.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("rollcalc_pipe_test", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"pydantic": pydantic, "httpx": httpx}):
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
PIPE_MODULE = _load_pipe_module()
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code, payload):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeClientFactory:
|
||||
def __init__(self, responses=None, error=None):
|
||||
self.responses = list(responses or [])
|
||||
self.error = error
|
||||
self.calls = []
|
||||
self.options = []
|
||||
|
||||
def __call__(self, **options):
|
||||
self.options.append(options)
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def post(self, url, **options):
|
||||
self.calls.append((url, options))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class OpenWebUIPipeTests(unittest.IsolatedAsyncioTestCase):
|
||||
def configured_pipe(self, factory):
|
||||
pipe = PIPE_MODULE.Pipe(client_factory=factory)
|
||||
pipe.valves.ROLLCALC_API_BASE_URL = "http://rollcalc.internal:5000"
|
||||
pipe.valves.ROLLCALC_PUBLIC_BASE_URL = "https://rollcalc.example.test"
|
||||
pipe.valves.ROLLCALC_USERNAME = "service-user"
|
||||
pipe.valves.ROLLCALC_PASSWORD = _SecretStr("secret")
|
||||
return pipe
|
||||
|
||||
@staticmethod
|
||||
def context(chat_id="chat-1"):
|
||||
return {
|
||||
"body": {"messages": [{"role": "user", "content": "Fallback"}]},
|
||||
"__user__": {"id": "user-1"},
|
||||
"__metadata__": {
|
||||
"chat_id": chat_id,
|
||||
"user_prompt": "Berechne die Rolle",
|
||||
},
|
||||
}
|
||||
|
||||
async def test_same_openwebui_chat_reuses_rollcalc_conversation(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {"message": "Welcher Kern?"}),
|
||||
FakeResponse(200, {"message": "Berechnung abgeschlossen."}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
first = await pipe.pipe(**self.context())
|
||||
second_context = self.context()
|
||||
second_context["__metadata__"]["user_prompt"] = "150 mm"
|
||||
second = await pipe.pipe(**second_context)
|
||||
|
||||
self.assertEqual(first, "Welcher Kern?")
|
||||
self.assertEqual(second, "Berechnung abgeschlossen.")
|
||||
self.assertEqual(len(factory.calls), 3)
|
||||
self.assertEqual(
|
||||
factory.calls[1][0],
|
||||
"http://rollcalc.internal:5000/api/conversations/rollcalc-1/messages",
|
||||
)
|
||||
self.assertEqual(factory.calls[2][0], factory.calls[1][0])
|
||||
self.assertEqual(factory.calls[2][1]["json"], {"message": "150 mm"})
|
||||
self.assertEqual(factory.options[0]["auth"].username, "service-user")
|
||||
self.assertEqual(factory.options[0]["auth"].password, "secret")
|
||||
|
||||
async def test_different_chat_gets_different_rollcalc_conversation(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {"message": "Antwort 1"}),
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-2"}),
|
||||
FakeResponse(200, {"message": "Antwort 2"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
await pipe.pipe(**self.context("chat-1"))
|
||||
await pipe.pipe(**self.context("chat-2"))
|
||||
|
||||
self.assertEqual(len(pipe._conversations), 2)
|
||||
self.assertEqual(
|
||||
set(pipe._conversations.values()),
|
||||
{"rollcalc-1", "rollcalc-2"},
|
||||
)
|
||||
|
||||
async def test_only_deterministic_message_and_public_pdf_link_are_shown(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {
|
||||
"message": (
|
||||
"Berechnung abgeschlossen.\n\n"
|
||||
"Artikel: Test (180205)\n\n"
|
||||
"PDF: /api/conversations/reports/report-1.pdf"
|
||||
),
|
||||
"pdf": {
|
||||
"url": "/api/conversations/reports/report-1.pdf",
|
||||
},
|
||||
"interpretation": {"internal": "must not leak"},
|
||||
"result": {"calculation": {"average_diameter_mm": 999}},
|
||||
}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("Berechnung abgeschlossen.", response)
|
||||
self.assertIn(
|
||||
"[PDF herunterladen](https://rollcalc.example.test/"
|
||||
"api/conversations/reports/report-1.pdf)",
|
||||
response,
|
||||
)
|
||||
self.assertNotIn("interpretation", response)
|
||||
self.assertNotIn("999", response)
|
||||
self.assertNotIn("PDF: /api", response)
|
||||
|
||||
async def test_invalid_report_path_is_not_exposed(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {
|
||||
"message": "Berechnung abgeschlossen.",
|
||||
"pdf": {"url": "http://internal-secret/report.pdf"},
|
||||
}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertEqual(response, "Berechnung abgeschlossen.")
|
||||
|
||||
async def test_expired_conversation_is_removed_without_replaying_message(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "expired"}),
|
||||
FakeResponse(404, {"status": "conversation_not_found"}),
|
||||
FakeResponse(201, {"conversation_id": "fresh"}),
|
||||
FakeResponse(200, {"message": "Neue Sitzung"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
retry = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("abgelaufen", response)
|
||||
self.assertEqual(retry, "Neue Sitzung")
|
||||
self.assertEqual(len(factory.calls), 4)
|
||||
|
||||
async def test_timeout_is_friendly_and_does_not_expose_exception(self):
|
||||
factory = FakeClientFactory(error=_TimeoutException("private details"))
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("zu lange", response)
|
||||
self.assertNotIn("private", response)
|
||||
|
||||
async def test_authentication_and_ollama_errors_are_friendly(self):
|
||||
cases = (
|
||||
(401, "Authentifizierung"),
|
||||
(503, "Spracherkennung"),
|
||||
)
|
||||
for status_code, expected in cases:
|
||||
with self.subTest(status_code=status_code):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(status_code, {"debug": "must not leak"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn(expected, response)
|
||||
self.assertNotIn("debug", response)
|
||||
|
||||
async def test_validation_error_uses_the_api_deterministic_message(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "conversation"}),
|
||||
FakeResponse(
|
||||
400,
|
||||
{
|
||||
"status": "invalid_parameter",
|
||||
"message": "Die Eingabe für roll_length_m ist ungültig.",
|
||||
},
|
||||
),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertEqual(response, "Die Eingabe für roll_length_m ist ungültig.")
|
||||
|
||||
async def test_missing_configuration_does_not_make_http_request(self):
|
||||
factory = FakeClientFactory()
|
||||
pipe = PIPE_MODULE.Pipe(client_factory=factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("nicht vollständig konfiguriert", response)
|
||||
self.assertEqual(factory.calls, [])
|
||||
|
||||
async def test_internal_openwebui_task_does_not_touch_conversation(self):
|
||||
factory = FakeClientFactory()
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(
|
||||
**self.context(),
|
||||
__task__="title_generation",
|
||||
)
|
||||
|
||||
self.assertEqual(response, "RollCalc")
|
||||
self.assertEqual(factory.calls, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,817 @@
|
||||
import json
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from roll_calculation import (
|
||||
ArticleRepository,
|
||||
CalculationRequest,
|
||||
CalculationState,
|
||||
_canonical_name,
|
||||
article_production_site,
|
||||
calculate_material_weight,
|
||||
calculate_material_length_for_weight,
|
||||
calculate_product_length,
|
||||
calculate_roll,
|
||||
get_article,
|
||||
modify_calculation,
|
||||
transport_roll_inputs_from_roll_calculation,
|
||||
)
|
||||
|
||||
|
||||
def representative_request(**changes):
|
||||
payload = {
|
||||
"article_number": "146900",
|
||||
"roll_length_m": 50.0,
|
||||
"width_m": 5.8,
|
||||
"core_diameter_mm": 150.0,
|
||||
"include_roll_weight": True,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class RollCalculationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = ArticleRepository(
|
||||
[
|
||||
{
|
||||
"nr": "00123",
|
||||
"name": "Alpha Product, 4,90 x 20 m",
|
||||
"thickness": 2.0,
|
||||
"thickness_stddev": 0.1,
|
||||
"area_weight": 1000.0,
|
||||
"core_type": 0.0,
|
||||
},
|
||||
{
|
||||
"nr": "00999",
|
||||
"name": "Product Without Width",
|
||||
"thickness": 3.0,
|
||||
"thickness_stddev": 0.0,
|
||||
"area_weight": 900.0,
|
||||
"core_type": 0.0,
|
||||
},
|
||||
{
|
||||
"nr": "01000",
|
||||
"name": "Duplicated Product",
|
||||
"thickness": 3.0,
|
||||
"thickness_stddev": 0.0,
|
||||
"area_weight": 900.0,
|
||||
"core_type": 0.0,
|
||||
},
|
||||
{
|
||||
"nr": "01001",
|
||||
"name": "Duplicated Product",
|
||||
"thickness": 3.0,
|
||||
"thickness_stddev": 0.0,
|
||||
"area_weight": 900.0,
|
||||
"core_type": 0.0,
|
||||
},
|
||||
{
|
||||
"nr": "02000",
|
||||
"name": "Shared Product, 4,90 x 20 m",
|
||||
"thickness": 2.0,
|
||||
"thickness_stddev": 0.1,
|
||||
"area_weight": 1000.0,
|
||||
"core_type": 0.0,
|
||||
},
|
||||
{
|
||||
"nr": "02001",
|
||||
"name": "Shared Product, 5,00 x 40 m",
|
||||
"thickness": 2.0,
|
||||
"thickness_stddev": 0.1,
|
||||
"area_weight": 1000.0,
|
||||
"core_type": 0.0,
|
||||
},
|
||||
{
|
||||
"nr": "03000",
|
||||
"name": "Bfix Alias Product",
|
||||
"thickness": 2.0,
|
||||
"thickness_stddev": 0.1,
|
||||
"area_weight": 1000.0,
|
||||
"core_type": 0.0,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
def calculate_fixture(self, payload):
|
||||
return calculate_roll(payload, article_repository=self.repository)
|
||||
|
||||
def test_public_get_article_uses_repository_resolution_and_projection(self):
|
||||
result = get_article("00123", article_repository=self.repository)
|
||||
|
||||
self.assertEqual(result["status"], "resolved")
|
||||
self.assertEqual(result["article"]["number"], "00123")
|
||||
self.assertEqual(result["article"]["width_m"], 4.9)
|
||||
self.assertEqual(result["article"]["area_weight_g_m2"], 1000.0)
|
||||
|
||||
def test_public_get_article_preserves_not_found_ambiguous_and_conflict_results(self):
|
||||
self.assertEqual(
|
||||
get_article("does-not-exist", article_repository=self.repository)["status"],
|
||||
"article_not_found",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_article(
|
||||
article_name_hint="Duplicated Product",
|
||||
article_repository=self.repository,
|
||||
)["status"],
|
||||
"article_ambiguous",
|
||||
)
|
||||
self.assertEqual(
|
||||
get_article(
|
||||
"00123",
|
||||
"Other product",
|
||||
article_repository=self.repository,
|
||||
)["status"],
|
||||
"article_conflict",
|
||||
)
|
||||
|
||||
def test_headless_matches_representative_existing_ui_values(self):
|
||||
result = calculate_roll(representative_request())
|
||||
calculation = result["calculation"]
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertAlmostEqual(calculation["minimum_diameter_mm"], 813.0877908536811)
|
||||
self.assertAlmostEqual(calculation["average_diameter_mm"], 845.8528172323103)
|
||||
self.assertAlmostEqual(calculation["maximum_diameter_mm"], 877.3951340213555)
|
||||
self.assertEqual(calculation["effective_roll_length_m"], 50.0)
|
||||
self.assertAlmostEqual(calculation["roll_weight_kg"], 512.4909)
|
||||
|
||||
def test_transport_roll_inputs_match_browser_average_and_display_rounding(self):
|
||||
result = calculate_roll(representative_request())
|
||||
|
||||
self.assertEqual(
|
||||
transport_roll_inputs_from_roll_calculation(result),
|
||||
{
|
||||
"roll_diameter_mm": 845.9,
|
||||
"core_diameter_mm": 150.0,
|
||||
"roll_width_m": 5.8,
|
||||
"roll_weight_kg": 512.5,
|
||||
"product_length_m": 50.0,
|
||||
},
|
||||
)
|
||||
|
||||
def test_transport_roll_inputs_use_browser_equivalent_half_rounding(self):
|
||||
result = {
|
||||
"status": "success",
|
||||
"calculation": {
|
||||
"average_diameter_mm": 1000.25,
|
||||
"roll_weight_kg": 12.25,
|
||||
"effective_roll_length_m": 10.0,
|
||||
},
|
||||
"effective_inputs": {
|
||||
"core_diameter_mm": {"value": 150.0},
|
||||
"width_m": {"value": 2.0},
|
||||
},
|
||||
}
|
||||
|
||||
inputs = transport_roll_inputs_from_roll_calculation(result)
|
||||
|
||||
self.assertEqual(inputs["roll_diameter_mm"], 1000.3)
|
||||
self.assertEqual(inputs["roll_weight_kg"], 12.3)
|
||||
|
||||
def test_transport_roll_inputs_reject_unsuccessful_or_incomplete_results(self):
|
||||
for result in (
|
||||
{"status": "needs_clarification"},
|
||||
{"status": "success", "calculation": {}, "effective_inputs": {}},
|
||||
):
|
||||
with self.subTest(result=result):
|
||||
with self.assertRaises(ValueError):
|
||||
transport_roll_inputs_from_roll_calculation(result)
|
||||
|
||||
def test_optional_weight_is_omitted_without_requesting_it(self):
|
||||
result = calculate_roll(
|
||||
representative_request(width_m=None, include_roll_weight=False)
|
||||
)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertIsNone(result["calculation"]["roll_weight_kg"])
|
||||
|
||||
def test_material_weight_does_not_require_or_assume_a_core_diameter(self):
|
||||
result = calculate_material_weight(
|
||||
roll_length_m=60,
|
||||
width_m=5.0,
|
||||
area_weight_g_m2=4850.079676,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["weight_scope"], "material_only")
|
||||
self.assertAlmostEqual(result["material_weight_kg"], 1455.0239028)
|
||||
self.assertNotIn("core_diameter_mm", result["inputs"])
|
||||
|
||||
def test_material_length_for_weight_is_material_only_and_round_trips(self):
|
||||
inverse = calculate_material_length_for_weight(
|
||||
target_material_weight_kg=700,
|
||||
width_m=6,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
|
||||
self.assertEqual(inverse["status"], "success")
|
||||
self.assertEqual(inverse["weight_scope"], "material_only")
|
||||
self.assertNotIn("core_diameter_mm", inverse["inputs"])
|
||||
round_trip = calculate_material_weight(
|
||||
roll_length_m=inverse["material_length_m"],
|
||||
width_m=6,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
self.assertAlmostEqual(round_trip["material_weight_kg"], 700)
|
||||
|
||||
def test_material_length_for_weight_rejects_non_positive_inputs(self):
|
||||
for changes in (
|
||||
{"target_material_weight_kg": 0},
|
||||
{"width_m": 0},
|
||||
{"area_weight_g_m2": 0},
|
||||
):
|
||||
with self.subTest(changes=changes):
|
||||
values = {
|
||||
"target_material_weight_kg": 700,
|
||||
"width_m": 6,
|
||||
"area_weight_g_m2": 495,
|
||||
}
|
||||
values.update(changes)
|
||||
self.assertEqual(
|
||||
calculate_material_length_for_weight(**values)["status"],
|
||||
"invalid_parameter",
|
||||
)
|
||||
|
||||
def test_roll_diameter_still_rejects_a_zero_core_diameter(self):
|
||||
result = calculate_roll(
|
||||
{
|
||||
"roll_length_m": 60,
|
||||
"width_m": 5.0,
|
||||
"area_weight_g_m2": 4850.079676,
|
||||
"thickness_mm": 6.461044,
|
||||
"core_diameter_mm": 0,
|
||||
"include_roll_weight": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertIn("core_diameter_mm", [
|
||||
item["field"] for item in result["invalid"]
|
||||
])
|
||||
|
||||
def test_product_length_characterizes_article_218500_with_ranges_and_weight(self):
|
||||
result = calculate_product_length(
|
||||
target_roll_diameter_mm=600,
|
||||
core_diameter_mm=140,
|
||||
thickness_mm=4.311611,
|
||||
thickness_stddev_mm=0.249999,
|
||||
width_m=6.0,
|
||||
area_weight_g_m2=495.395833,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
calculation = result["calculation"]
|
||||
self.assertAlmostEqual(calculation["minimum_product_length_m"], 55.5634372661)
|
||||
self.assertAlmostEqual(calculation["average_product_length_m"], 62.0068774341)
|
||||
self.assertAlmostEqual(calculation["maximum_product_length_m"], 70.1407868061)
|
||||
self.assertAlmostEqual(
|
||||
calculation["material_weights_kg"]["average_kg"], 184.3076921892
|
||||
)
|
||||
|
||||
def test_product_length_rejects_target_diameter_not_larger_than_core(self):
|
||||
result = calculate_product_length(
|
||||
target_roll_diameter_mm=140,
|
||||
core_diameter_mm=140,
|
||||
thickness_mm=4,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(
|
||||
result["invalid"][0]["field"], "target_roll_diameter_mm"
|
||||
)
|
||||
|
||||
def test_diameter_only_calculation_does_not_require_width(self):
|
||||
result = self.calculate_fixture(
|
||||
{
|
||||
"article_number": "00999",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertNotIn("width_m", result["effective_inputs"])
|
||||
self.assertIsNone(result["calculation"]["roll_weight_kg"])
|
||||
|
||||
def test_missing_core_diameter_blocks_diameter_calculation(self):
|
||||
result = self.calculate_fixture(
|
||||
{
|
||||
"article_number": "00999",
|
||||
"roll_length_m": 20,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "needs_clarification")
|
||||
self.assertIn("core_diameter_mm", result["missing"])
|
||||
self.assertNotIn("width_m", result["missing"])
|
||||
|
||||
def test_warning_and_note_rules_are_preserved(self):
|
||||
heavy = calculate_roll(
|
||||
{
|
||||
"roll_length_m": 100,
|
||||
"width_m": 4,
|
||||
"thickness_mm": 5,
|
||||
"area_weight_g_m2": 5000,
|
||||
"core_diameter_mm": 150,
|
||||
"category": "bentofix",
|
||||
"include_roll_weight": True,
|
||||
}
|
||||
)
|
||||
normal = calculate_roll(representative_request(category="bentofix"))
|
||||
|
||||
self.assertEqual(heavy["warnings"][0]["severity"], "warning")
|
||||
self.assertIn("min 170 mm required", heavy["warnings"][0]["message"])
|
||||
self.assertIn("within standard limits", normal["notes"][0])
|
||||
|
||||
def test_exact_article_number_preserves_leading_zeroes(self):
|
||||
result = self.calculate_fixture(
|
||||
{"article_number": "00123", "roll_length_m": 20, "core_diameter_mm": 150}
|
||||
)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["resolved_article"]["number"], "00123")
|
||||
numeric = self.calculate_fixture(
|
||||
{"article_number": 123, "roll_length_m": 20, "core_diameter_mm": 150}
|
||||
)
|
||||
self.assertEqual(numeric["status"], "invalid_parameter")
|
||||
|
||||
def test_exact_normalized_article_name_resolves_without_number(self):
|
||||
result = self.calculate_fixture({
|
||||
"article_name_hint": "Alpha Product, 4,90 x 20 m",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
})
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["resolved_article"]["number"], "00123")
|
||||
|
||||
def test_article_name_matching_is_case_insensitive(self):
|
||||
result = self.calculate_fixture({
|
||||
"article_name_hint": "pRoDuCt WiThOuT wIdTh",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
})
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["resolved_article"]["number"], "00999")
|
||||
|
||||
def test_article_name_normalizes_safe_punctuation_and_whitespace(self):
|
||||
result = self.calculate_fixture({
|
||||
"article_name_hint": " Alpha---Product ; 4.90 × 20 M ",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
})
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["resolved_article"]["number"], "00123")
|
||||
|
||||
def test_model_designator_spacing_matches_the_same_article_family(self):
|
||||
repository = ArticleRepository([
|
||||
{"nr": "04000", "name": "Stex R 501, 5,20 x 100 m"},
|
||||
{"nr": "04001", "name": "Stex R 501, 6,00 x 100 m"},
|
||||
])
|
||||
|
||||
resolution = repository.resolve(None, "Stex R501")
|
||||
|
||||
self.assertEqual(resolution["status"], "article_ambiguous")
|
||||
self.assertEqual(
|
||||
[candidate["article_number"] for candidate in resolution["candidates"]],
|
||||
["04000", "04001"],
|
||||
)
|
||||
|
||||
def test_base_name_ignores_dimensions_and_parenthetical_descriptor(self):
|
||||
repository = ArticleRepository([
|
||||
{
|
||||
"nr": "04000",
|
||||
"name": "Stex R 501 (Betonvlies), 5,20 x 100 m",
|
||||
},
|
||||
])
|
||||
|
||||
resolution = repository.resolve(None, "Stex R-501")
|
||||
|
||||
self.assertEqual(resolution["status"], "resolved")
|
||||
self.assertEqual(resolution["article"]["number"], "04000")
|
||||
|
||||
def test_secutex_and_stex_r501_have_the_same_ambiguous_candidates(self):
|
||||
repository = ArticleRepository([
|
||||
{"nr": "04000", "name": "Stex R 501, 5,20 x 100 m"},
|
||||
{"nr": "04001", "name": "Stex R 501, 6,00 x 100 m"},
|
||||
])
|
||||
|
||||
spaced = repository.resolve(None, "Secutex R 501")
|
||||
compact = repository.resolve(None, "sEcUtEx R501")
|
||||
stored = repository.resolve(None, "Stex R501")
|
||||
|
||||
self.assertEqual(spaced["status"], "article_ambiguous")
|
||||
self.assertEqual(compact["status"], "article_ambiguous")
|
||||
self.assertEqual(
|
||||
spaced["candidates"],
|
||||
stored["candidates"],
|
||||
)
|
||||
self.assertEqual(compact["candidates"], stored["candidates"])
|
||||
|
||||
def test_bentofix_and_bfix_resolve_equivalently(self):
|
||||
repository = ArticleRepository([
|
||||
{"nr": "04000", "name": "Bfix NSP 4900, 5,00 x 40 m"},
|
||||
])
|
||||
|
||||
full = repository.resolve(None, "Bentofix NSP 4900")
|
||||
abbreviated = repository.resolve(None, "Bfix NSP 4900")
|
||||
|
||||
self.assertEqual(full, abbreviated)
|
||||
self.assertEqual(full["status"], "resolved")
|
||||
|
||||
def test_secugrid_and_sgrid_have_the_same_family_matching_behavior(self):
|
||||
repository = ArticleRepository([
|
||||
{"nr": "04000", "name": "Sgrid 40/40 Q6, 4,75 x 100 m"},
|
||||
{"nr": "04001", "name": "Sgrid 40/40 Q6, 4,75 x 50 m"},
|
||||
])
|
||||
|
||||
full = repository.resolve(None, "Secugrid 40/40 Q6")
|
||||
abbreviated = repository.resolve(None, "Sgrid 40/40 Q6")
|
||||
|
||||
self.assertEqual(full["status"], "article_ambiguous")
|
||||
self.assertEqual(full["candidates"], abbreviated["candidates"])
|
||||
|
||||
def test_product_family_aliases_are_token_bounded(self):
|
||||
self.assertEqual(_canonical_name("Secutex R501"), "stex r501")
|
||||
self.assertEqual(_canonical_name("Disecutex R501"), "disecutex r501")
|
||||
self.assertEqual(_canonical_name("Bentofixation NSP4900"), "bentofixation nsp 4900")
|
||||
|
||||
def test_weak_fuzzy_match_is_not_silently_selected(self):
|
||||
resolution = self.repository.resolve(None, "Alphx Product")
|
||||
|
||||
self.assertEqual(resolution["status"], "article_ambiguous")
|
||||
self.assertEqual(resolution["reason"], "uncertain_article_match")
|
||||
self.assertEqual(
|
||||
resolution["candidates"][0]["article_number"],
|
||||
"00123",
|
||||
)
|
||||
|
||||
def test_secutex_r501_alias_returns_only_repository_candidates(self):
|
||||
repository = ArticleRepository.load()
|
||||
resolution = repository.resolve(None, "Secutex R501")
|
||||
|
||||
self.assertEqual(resolution["status"], "article_ambiguous")
|
||||
candidate_numbers = {
|
||||
candidate["article_number"] for candidate in resolution["candidates"]
|
||||
}
|
||||
self.assertIn("214507", candidate_numbers)
|
||||
self.assertIn("214508", candidate_numbers)
|
||||
self.assertTrue(candidate_numbers <= set(repository.by_number))
|
||||
|
||||
def test_shared_base_name_returns_repository_candidates(self):
|
||||
result = self.calculate_fixture({
|
||||
"article_name_hint": "Shared Product",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
})
|
||||
|
||||
self.assertEqual(result["status"], "needs_clarification")
|
||||
self.assertEqual(result["field"], "article")
|
||||
self.assertEqual(result["reason"], "ambiguous_article")
|
||||
self.assertEqual(result["candidates"], [
|
||||
{
|
||||
"article_number": "02000",
|
||||
"name": "Shared Product, 4,90 x 20 m",
|
||||
},
|
||||
{
|
||||
"article_number": "02001",
|
||||
"name": "Shared Product, 5,00 x 40 m",
|
||||
},
|
||||
])
|
||||
|
||||
def test_unknown_conflicting_and_ambiguous_articles_are_structured(self):
|
||||
unknown = self.calculate_fixture(
|
||||
{"article_number": "404", "roll_length_m": 20, "core_diameter_mm": 150}
|
||||
)
|
||||
conflict = self.calculate_fixture(
|
||||
{
|
||||
"article_number": "00123",
|
||||
"article_name_hint": "Different Product",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
}
|
||||
)
|
||||
ambiguous = self.calculate_fixture(
|
||||
{
|
||||
"article_name_hint": "Duplicated Product",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
}
|
||||
)
|
||||
self.assertEqual(unknown["status"], "article_not_found")
|
||||
self.assertEqual(conflict["status"], "article_conflict")
|
||||
self.assertEqual(ambiguous["status"], "needs_clarification")
|
||||
self.assertEqual(ambiguous["ambiguous"], ["article_name_hint"])
|
||||
|
||||
def test_duplicate_exact_article_number_is_never_guessed(self):
|
||||
repository = ArticleRepository(
|
||||
[
|
||||
{
|
||||
"nr": "00123",
|
||||
"name": "First Record",
|
||||
"thickness": 2.0,
|
||||
},
|
||||
{
|
||||
"nr": "00123",
|
||||
"name": "Conflicting Record",
|
||||
"thickness": 3.0,
|
||||
},
|
||||
]
|
||||
)
|
||||
result = calculate_roll(
|
||||
{
|
||||
"article_number": "00123",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
},
|
||||
article_repository=repository,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "needs_clarification")
|
||||
self.assertEqual(result["ambiguous"], ["article_number"])
|
||||
self.assertEqual(len(result["article_resolution"]["matches"]), 2)
|
||||
|
||||
def test_name_hint_validation_accepts_deterministic_bfix_alias(self):
|
||||
result = calculate_roll(
|
||||
{
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65,
|
||||
"core_diameter_mm": 150,
|
||||
}
|
||||
)
|
||||
self.assertEqual(result["status"], "success")
|
||||
|
||||
def test_search_exact_visible_name_keeps_duplicate_article_numbers(self):
|
||||
result = ArticleRepository.load().search("Bfix NSP 4900, 5,00 x 40 m")
|
||||
|
||||
self.assertEqual(result["status"], "search_results")
|
||||
self.assertEqual(result["total_matches"], 4)
|
||||
self.assertEqual(result["candidates"][:2], [
|
||||
{
|
||||
"article_number": "180205",
|
||||
"name": "Bfix NSP 4900, 5,00 x 40 m",
|
||||
"width_m": 5.0,
|
||||
"production_site": None,
|
||||
},
|
||||
{
|
||||
"article_number": "8180205",
|
||||
"name": "Bfix NSP 4900, 5,00 x 40 m",
|
||||
"width_m": 5.0,
|
||||
"production_site": "Malaysia",
|
||||
},
|
||||
])
|
||||
|
||||
def test_article_number_production_site_rule_only_identifies_malaysia(self):
|
||||
self.assertEqual(article_production_site("8180205"), "Malaysia")
|
||||
self.assertIsNone(article_production_site("180205"))
|
||||
|
||||
def test_search_candidates_expose_site_without_changing_order_or_resolve(self):
|
||||
repository = ArticleRepository.load()
|
||||
candidates = repository.search(
|
||||
"Bfix NSP 4900, 5,00 x 40 m"
|
||||
)["candidates"]
|
||||
|
||||
self.assertEqual(
|
||||
[candidate["article_number"] for candidate in candidates],
|
||||
["180205", "8180205", "182815", "206900"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[candidate["production_site"] for candidate in candidates],
|
||||
[None, "Malaysia", None, None],
|
||||
)
|
||||
resolved = repository.resolve("8180205", None)
|
||||
self.assertEqual(resolved["status"], "resolved")
|
||||
self.assertNotIn("production_site", resolved["article"])
|
||||
|
||||
def test_search_bentofix_alias_matches_bfix_without_resolving_one_article(self):
|
||||
repository = ArticleRepository.load()
|
||||
|
||||
full_name = repository.search("Bentofix NSP 4900")
|
||||
abbreviated = repository.search("Bfix NSP 4900")
|
||||
|
||||
self.assertEqual(full_name["candidates"], abbreviated["candidates"])
|
||||
self.assertGreater(full_name["total_matches"], 2)
|
||||
self.assertIn("180205", [
|
||||
candidate["article_number"] for candidate in full_name["candidates"]
|
||||
])
|
||||
|
||||
def test_search_partial_name_returns_related_products_without_resolving_one(self):
|
||||
result = ArticleRepository.load().search("NSP 4900")
|
||||
|
||||
self.assertEqual(result["status"], "search_results")
|
||||
self.assertGreater(result["total_matches"], 4)
|
||||
self.assertIn("180205", [
|
||||
candidate["article_number"] for candidate in result["candidates"]
|
||||
])
|
||||
self.assertIn("206900", [
|
||||
candidate["article_number"] for candidate in result["candidates"]
|
||||
])
|
||||
|
||||
def test_search_qualifiers_and_dimensions_narrow_real_data_results(self):
|
||||
repository = ArticleRepository.load()
|
||||
base = repository.search("Bfix NSP 4900")
|
||||
dimensions = repository.search("Bfix NSP 4900, 5,00 x 40 m")
|
||||
qualified = repository.search("Bfix NSP 4900 GRI GCL3")
|
||||
|
||||
self.assertLess(dimensions["total_matches"], base["total_matches"])
|
||||
self.assertEqual(
|
||||
[candidate["article_number"] for candidate in qualified["candidates"]],
|
||||
["206900"],
|
||||
)
|
||||
|
||||
def test_search_existing_family_aliases_are_equivalent(self):
|
||||
repository = ArticleRepository.load()
|
||||
|
||||
self.assertEqual(
|
||||
repository.search("Secutex R 1801")["candidates"],
|
||||
repository.search("StEx R 1801")["candidates"],
|
||||
)
|
||||
self.assertEqual(
|
||||
repository.search("Secugrid 40/40 Q6")["candidates"],
|
||||
repository.search("Sgrid 40/40 Q6")["candidates"],
|
||||
)
|
||||
|
||||
def test_search_is_stable_and_handles_empty_and_unknown_queries(self):
|
||||
repository = ArticleRepository.load()
|
||||
|
||||
self.assertEqual(
|
||||
repository.search("NSP 4900"), repository.search("NSP 4900")
|
||||
)
|
||||
self.assertEqual(repository.search(" "), {
|
||||
"status": "search_not_found",
|
||||
"query": "",
|
||||
"total_matches": 0,
|
||||
"candidates": [],
|
||||
})
|
||||
self.assertEqual(
|
||||
repository.search("not a RollCalc product")["status"],
|
||||
"search_not_found",
|
||||
)
|
||||
|
||||
def test_search_reports_total_when_the_stable_result_list_is_capped(self):
|
||||
result = ArticleRepository.load().search("Secugrid")
|
||||
|
||||
self.assertEqual(result["status"], "search_results")
|
||||
self.assertGreater(result["total_matches"], len(result["candidates"]))
|
||||
self.assertEqual(len(result["candidates"]), 20)
|
||||
|
||||
def test_search_does_not_change_existing_resolve_ambiguity(self):
|
||||
repository = ArticleRepository.load()
|
||||
|
||||
self.assertEqual(
|
||||
repository.resolve(None, "Bentofix NSP 4900")["status"],
|
||||
"article_ambiguous",
|
||||
)
|
||||
|
||||
def test_name_only_matching_uses_deterministic_bfix_alias(self):
|
||||
result = self.calculate_fixture({
|
||||
"article_name_hint": "Bentofix Alias Product",
|
||||
"roll_length_m": 65,
|
||||
"core_diameter_mm": 150,
|
||||
})
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["resolved_article"]["number"], "03000")
|
||||
|
||||
def test_missing_width_requests_clarification_when_not_in_master_data(self):
|
||||
result = self.calculate_fixture(
|
||||
{
|
||||
"article_number": "00999",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
"include_roll_weight": True,
|
||||
}
|
||||
)
|
||||
self.assertEqual(result["status"], "needs_clarification")
|
||||
self.assertIn("width_m", result["missing"])
|
||||
|
||||
def test_unambiguous_master_width_is_used_with_provenance(self):
|
||||
result = self.calculate_fixture(
|
||||
{
|
||||
"article_number": "00123",
|
||||
"roll_length_m": 20,
|
||||
"core_diameter_mm": 150,
|
||||
"include_roll_weight": True,
|
||||
}
|
||||
)
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["effective_inputs"]["width_m"], {
|
||||
"value": 4.9,
|
||||
"source": "article_master_data",
|
||||
})
|
||||
self.assertAlmostEqual(result["calculation"]["roll_weight_kg"], 98.0)
|
||||
|
||||
def test_invalid_values_and_caller_results_are_rejected(self):
|
||||
invalid = calculate_roll(
|
||||
{"roll_length_m": -1, "thickness_mm": 2, "core_diameter_mm": 150}
|
||||
)
|
||||
supplied = calculate_roll(
|
||||
{
|
||||
"roll_length_m": 10,
|
||||
"thickness_mm": 2,
|
||||
"core_diameter_mm": 150,
|
||||
"diameters": {"average": 1},
|
||||
}
|
||||
)
|
||||
self.assertEqual(invalid["status"], "invalid_parameter")
|
||||
self.assertEqual(supplied["status"], "invalid_parameter")
|
||||
self.assertIn("calculated result fields", supplied["invalid"][0]["message"])
|
||||
self.assertNotIn("request", supplied)
|
||||
|
||||
def test_extreme_values_cannot_produce_non_finite_results(self):
|
||||
result = calculate_roll(
|
||||
{
|
||||
"roll_length_m": 1e308,
|
||||
"thickness_mm": 1e308,
|
||||
"core_diameter_mm": 150,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertIn("supported calculation range", result["invalid"][0]["message"])
|
||||
|
||||
def test_request_result_and_state_are_json_serializable(self):
|
||||
request = CalculationRequest.from_dict(representative_request())
|
||||
state = CalculationState(request)
|
||||
result = calculate_roll(request)
|
||||
json.dumps(state.to_dict())
|
||||
json.dumps(result)
|
||||
|
||||
|
||||
class CalculationMutationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.state = CalculationState(
|
||||
CalculationRequest.from_dict(representative_request())
|
||||
)
|
||||
|
||||
def test_changing_length_recalculates_from_structured_state(self):
|
||||
result = modify_calculation(self.state, {"roll_length_m": 80})
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0)
|
||||
self.assertGreater(
|
||||
result["calculation"]["average_diameter_mm"], 845.8528172323103
|
||||
)
|
||||
|
||||
def test_changing_width_recalculates_weight(self):
|
||||
result = modify_calculation(self.state, {"width_m": 4.9})
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertAlmostEqual(result["calculation"]["roll_weight_kg"], 432.96645)
|
||||
|
||||
def test_multiple_changes_are_applied_as_one_validated_state(self):
|
||||
result = modify_calculation(
|
||||
self.state,
|
||||
{"roll_length_m": 80, "width_m": 5.0, "include_roll_weight": True},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
request = result["state"]["request"]
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["width_m"], 5.0)
|
||||
self.assertTrue(request["include_roll_weight"])
|
||||
self.assertIsNotNone(result["calculation"]["roll_weight_kg"])
|
||||
|
||||
def test_invalid_multi_change_does_not_mutate_the_existing_state(self):
|
||||
original = self.state.to_dict()
|
||||
result = modify_calculation(
|
||||
self.state,
|
||||
{"roll_length_m": 80, "unknown_input": 5.0},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(self.state.to_dict(), original)
|
||||
|
||||
def test_valid_core_change_recalculates(self):
|
||||
result = modify_calculation(self.state, {"core_type": "194mm steel"})
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["effective_inputs"]["core_diameter_mm"]["value"], 194.0)
|
||||
self.assertGreater(
|
||||
result["calculation"]["average_diameter_mm"], 845.8528172323103
|
||||
)
|
||||
|
||||
def test_ambiguous_or_unknown_core_change_never_guesses(self):
|
||||
ambiguous = modify_calculation(self.state, {"core_type": "steel"})
|
||||
unknown = modify_calculation(self.state, {"core_type": "cardboard"})
|
||||
self.assertEqual(ambiguous["status"], "needs_clarification")
|
||||
self.assertEqual(unknown["status"], "needs_clarification")
|
||||
self.assertIn("core_type", ambiguous["ambiguous"])
|
||||
self.assertIn("core_type", unknown["ambiguous"])
|
||||
|
||||
def test_caller_result_fields_are_rejected_from_state(self):
|
||||
result = modify_calculation(
|
||||
{
|
||||
"request": self.state.request.to_dict(),
|
||||
"calculation": {"average_diameter_mm": 1},
|
||||
},
|
||||
{"roll_length_m": 80},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertIn("unknown state fields", result["invalid"][0]["message"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TemplatePdfIntegrationTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.template = (PROJECT_ROOT / "templates" / "roll_calculator.html").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
cls.pdf_report = (PROJECT_ROOT / "pdf_report.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_ui_uses_the_headless_calculation_result(self):
|
||||
self.assertIn("fetch('/api/calculations/roll'", self.template)
|
||||
self.assertIn("calculation.average_diameter_mm", self.template)
|
||||
self.assertIn("calculation.effective_roll_length_m", self.template)
|
||||
|
||||
def test_product_length_ui_uses_the_shared_domain_endpoint(self):
|
||||
self.assertIn("fetch('/api/calculations/product-length'", self.template)
|
||||
self.assertIn("calculation.average_product_length_m", self.template)
|
||||
self.assertNotIn("const L_calc = (Math.PI / (4 * t))", self.template)
|
||||
self.assertNotIn("function calculateDiameterResult", self.template)
|
||||
|
||||
def test_optional_weight_requires_both_weight_inputs(self):
|
||||
self.assertIn(
|
||||
"include_roll_weight: width !== null && areaWeight !== null",
|
||||
self.template,
|
||||
)
|
||||
|
||||
def test_pdf_action_starts_disabled_and_posts_structured_result(self):
|
||||
self.assertIn('id="d-pdf" type="button" disabled', self.template)
|
||||
self.assertIn("body: JSON.stringify(window.CURRENT_ROLL_REQUEST)", self.template)
|
||||
self.assertIn("function invalidatePdfReport()", self.template)
|
||||
|
||||
def test_pdf_layout_uses_four_primary_result_cards(self):
|
||||
self.assertIn('"CALCULATION RESULTS"', self.pdf_report)
|
||||
self.assertIn('(("ROLL", "LENGTH"), report["roll_length"]["display"])', self.pdf_report)
|
||||
|
||||
def test_direct_forklift_display_consumes_service_warning_or_note(self):
|
||||
self.assertIn("renderAuthoritativeForkliftResult(authoritativeResult)", self.template)
|
||||
self.assertIn("const warning = result.warnings?.[0]", self.template)
|
||||
self.assertIn("const note = result.notes?.[0]", self.template)
|
||||
|
||||
def test_transport_ui_uses_shared_api_without_a_browser_capacity_calculator(self):
|
||||
self.assertIn("fetch('/api/calculations/transport'", self.template)
|
||||
self.assertIn("this.showResults(payload.analysis)", self.template)
|
||||
self.assertNotIn("calculateLoadUtilization", self.template)
|
||||
self.assertNotIn("class LoadOptimizer {", self.template)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from transport_calculation import analyze_transport, transport_presets
|
||||
|
||||
|
||||
def request(**changes):
|
||||
payload = {
|
||||
"length_m": 10.0,
|
||||
"width_m": 5.0,
|
||||
"height_m": 5.0,
|
||||
"max_weight_kg": 10_000.0,
|
||||
"margin_side_m": 0.0,
|
||||
"margin_ceiling_m": 0.0,
|
||||
"roll_diameter_mm": 1000.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
"roll_width_m": 1.0,
|
||||
"roll_weight_kg": 1000.0,
|
||||
"product_length_m": 10.0,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class TransportCalculationTests(unittest.TestCase):
|
||||
def analysis(self, **changes):
|
||||
result = analyze_transport(request(**changes))
|
||||
self.assertEqual(result["status"], "success")
|
||||
return result["analysis"]
|
||||
|
||||
def test_active_presets_match_golden_browser_results(self):
|
||||
expected = {
|
||||
"container_20ft": (8, "Geometry", [21, 19, 8], (2, 2, 2)),
|
||||
"container_40ft": (24, "Geometry", [30, 40, 24], (6, 2, 2)),
|
||||
"container_40ft_hc": (24, "Geometry", [30, 46, 24], (6, 2, 2)),
|
||||
"lkw_sattelzug": (24, "Weight", [24, 54, 24], (6, 2, 2)),
|
||||
"lkw_tandem": (24, "Geometry", [25, 54, 24], (6, 2, 2)),
|
||||
}
|
||||
for preset in transport_presets():
|
||||
with self.subTest(preset=preset.key):
|
||||
result = analyze_transport({
|
||||
"transport_preset": preset.key,
|
||||
"margin_side_m": 0.0,
|
||||
"margin_ceiling_m": 0.2,
|
||||
"roll_diameter_mm": 1000.0,
|
||||
"core_diameter_mm": 150.0,
|
||||
"roll_width_m": 2.0,
|
||||
"roll_weight_kg": 1000.0,
|
||||
"product_length_m": 50.0,
|
||||
})
|
||||
analysis = result["analysis"]
|
||||
rolls, limiting, scenarios, layout = expected[preset.key]
|
||||
self.assertEqual(analysis["final_rolls"], rolls)
|
||||
self.assertEqual(analysis["limiting"]["name"], limiting)
|
||||
self.assertEqual(
|
||||
[scenario["rolls"] for scenario in analysis["scenarios"]],
|
||||
scenarios,
|
||||
)
|
||||
self.assertEqual(
|
||||
(
|
||||
analysis["layout"]["rolls_along_length"],
|
||||
analysis["layout"]["rolls_along_width"],
|
||||
analysis["layout"]["max_stack_height"],
|
||||
),
|
||||
layout,
|
||||
)
|
||||
|
||||
def test_custom_weight_governing_case_matches_browser_semantics(self):
|
||||
analysis = self.analysis()
|
||||
self.assertEqual(analysis["limiting"]["name"], "Weight")
|
||||
self.assertEqual(analysis["final_rolls"], 10)
|
||||
self.assertEqual(analysis["remaining_payload_kg"], 0.0)
|
||||
self.assertEqual(analysis["final_m2"], 100.0)
|
||||
|
||||
def test_geometry_governing_and_impossible_geometry_are_preserved(self):
|
||||
geometry = self.analysis(max_weight_kg=1_000_000.0)
|
||||
self.assertEqual(geometry["limiting"]["name"], "Geometry")
|
||||
self.assertEqual(geometry["final_rolls"], 250)
|
||||
|
||||
impossible = self.analysis(
|
||||
length_m=2.0,
|
||||
width_m=2.0,
|
||||
height_m=2.0,
|
||||
roll_diameter_mm=3000.0,
|
||||
max_weight_kg=100_000.0,
|
||||
)
|
||||
self.assertEqual(impossible["limiting"]["name"], "Geometry")
|
||||
self.assertEqual(impossible["final_rolls"], 0)
|
||||
self.assertEqual(impossible["layout"]["rolls_along_width"], 0)
|
||||
|
||||
def test_volume_governing_case_preserves_unvalidated_browser_sign_behavior(self):
|
||||
# Browser getRollData() rejects zero but not a negative diameter. This
|
||||
# non-physical case characterizes its otherwise reachable volume limit.
|
||||
analysis = self.analysis(
|
||||
roll_diameter_mm=-2400.0,
|
||||
max_weight_kg=100_000.0,
|
||||
)
|
||||
self.assertEqual(analysis["limiting"]["name"], "Volume")
|
||||
self.assertEqual([s["rolls"] for s in analysis["scenarios"]], [100, 55, 90])
|
||||
|
||||
def test_weight_volume_geometry_tie_order_is_weight_volume_geometry(self):
|
||||
analysis = self.analysis(max_weight_kg=250_000.0)
|
||||
self.assertEqual([s["rolls"] for s in analysis["scenarios"]], [250, 325, 250])
|
||||
self.assertEqual(analysis["limiting"]["name"], "Weight")
|
||||
|
||||
def test_floor_and_clearance_behavior_matches_browser_math_floor(self):
|
||||
below_boundary = self.analysis(max_weight_kg=999.9)
|
||||
at_boundary = self.analysis(max_weight_kg=1000.0)
|
||||
self.assertEqual(below_boundary["final_rolls"], 0)
|
||||
self.assertEqual(at_boundary["final_rolls"], 1)
|
||||
|
||||
clearance = self.analysis(margin_side_m=0.5, margin_ceiling_m=1.0)
|
||||
self.assertEqual(clearance["layout"]["effective_width_m"], 4.5)
|
||||
self.assertEqual(clearance["layout"]["effective_height_m"], 4.0)
|
||||
self.assertEqual(clearance["layout"]["rolls_along_width"], 4)
|
||||
self.assertEqual(clearance["layout"]["max_stack_height"], 4)
|
||||
|
||||
def test_displayed_one_decimal_roll_weight_is_consumed_without_recalculation(self):
|
||||
# The direct UI writes d-wo using toFixed(1), then transport reads it.
|
||||
displayed = self.analysis(roll_weight_kg=1000.0)
|
||||
unrounded = self.analysis(roll_weight_kg=1000.04)
|
||||
self.assertEqual(displayed["final_rolls"], 10)
|
||||
self.assertEqual(unrounded["final_rolls"], 9)
|
||||
|
||||
def test_existing_ui_validation_boundary_is_structured_for_the_api(self):
|
||||
for field, value in (("length_m", 0), ("width_m", -1), ("max_weight_kg", 0)):
|
||||
with self.subTest(field=field):
|
||||
result = analyze_transport(request(**{field: value}))
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(result["invalid"][0]["field"], field)
|
||||
|
||||
result = analyze_transport(request(roll_weight_kg=0))
|
||||
self.assertEqual(result["status"], "invalid_parameter")
|
||||
self.assertEqual(result["invalid"][0]["field"], "roll_weight_kg")
|
||||
|
||||
def test_missing_product_length_preserves_browser_zero_square_meter_behavior(self):
|
||||
payload = request()
|
||||
del payload["product_length_m"]
|
||||
result = analyze_transport(payload)
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(result["analysis"]["final_m2"], 0.0)
|
||||
|
||||
def test_volume_and_utilization_values_are_golden(self):
|
||||
analysis = self.analysis()
|
||||
self.assertAlmostEqual(analysis["volume_per_roll_m3"], 0.7677267047210057)
|
||||
self.assertAlmostEqual(analysis["utilization_weight"], 100.0)
|
||||
self.assertAlmostEqual(analysis["utilization_volume"], 3.0709068188840227)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Shared transport-capacity calculation extracted from the active UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
TransportPresetKey = Literal[
|
||||
"container_20ft",
|
||||
"container_40ft",
|
||||
"container_40ft_hc",
|
||||
"lkw_sattelzug",
|
||||
"lkw_tandem",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportPreset:
|
||||
key: str
|
||||
name: str
|
||||
length_m: float
|
||||
width_m: float
|
||||
height_m: float
|
||||
max_weight_kg: float
|
||||
|
||||
|
||||
# Preserve the active browser order and values exactly.
|
||||
TRANSPORT_PRESETS = (
|
||||
TransportPreset("container_20ft", "20ft Container", 5.90, 2.35, 2.39, 21600),
|
||||
TransportPreset("container_40ft", "40ft Container", 12.19, 2.35, 2.39, 30480),
|
||||
TransportPreset("container_40ft_hc", "40ft High Cube", 12.19, 2.35, 2.69, 30480),
|
||||
TransportPreset("lkw_sattelzug", "LKW Sattelzug", 13.50, 2.50, 2.70, 24000),
|
||||
TransportPreset("lkw_tandem", "LKW Tandem", 13.50, 2.50, 2.70, 25000),
|
||||
)
|
||||
PRESETS_BY_KEY = {preset.key: preset for preset in TRANSPORT_PRESETS}
|
||||
|
||||
|
||||
def transport_presets() -> tuple[TransportPreset, ...]:
|
||||
"""Return the canonical active transport presets."""
|
||||
return TRANSPORT_PRESETS
|
||||
|
||||
|
||||
def _invalid(message: str, field: str = "request") -> dict[str, Any]:
|
||||
return {
|
||||
"status": "invalid_parameter",
|
||||
"invalid": [{"field": field, "message": message}],
|
||||
}
|
||||
|
||||
|
||||
def _number(payload: dict[str, Any], field: str) -> float | None:
|
||||
value = payload.get(field)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{field} must be a number")
|
||||
value = float(value)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"{field} must be finite")
|
||||
return value
|
||||
|
||||
|
||||
def _json_number(value: float) -> float | None:
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def analyze_transport(payload: Any) -> dict[str, Any]:
|
||||
"""Calculate transport capacity using the active LoadOptimizer semantics.
|
||||
|
||||
Roll weight is deliberately consumed as supplied. The browser supplies the
|
||||
one-decimal displayed weight from the direct-calculation tab, which is an
|
||||
observable part of the legacy transport behavior.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return _invalid("request must be a JSON object")
|
||||
|
||||
allowed = {
|
||||
"transport_preset",
|
||||
"length_m",
|
||||
"width_m",
|
||||
"height_m",
|
||||
"max_weight_kg",
|
||||
"margin_side_m",
|
||||
"margin_ceiling_m",
|
||||
"roll_diameter_mm",
|
||||
"core_diameter_mm",
|
||||
"roll_width_m",
|
||||
"roll_weight_kg",
|
||||
"product_length_m",
|
||||
}
|
||||
unknown = sorted(set(payload) - allowed)
|
||||
if unknown:
|
||||
return _invalid("unknown request fields: " + ", ".join(unknown))
|
||||
|
||||
try:
|
||||
preset_key = payload.get("transport_preset")
|
||||
if preset_key is not None and not isinstance(preset_key, str):
|
||||
raise ValueError("transport_preset must be text")
|
||||
preset = PRESETS_BY_KEY.get(preset_key) if preset_key else None
|
||||
if preset_key and preset is None:
|
||||
return _invalid("unknown transport preset", "transport_preset")
|
||||
|
||||
length = _number(payload, "length_m")
|
||||
width = _number(payload, "width_m")
|
||||
height = _number(payload, "height_m")
|
||||
max_weight = _number(payload, "max_weight_kg")
|
||||
if preset and all(value is None for value in (length, width, height, max_weight)):
|
||||
length = preset.length_m
|
||||
width = preset.width_m
|
||||
height = preset.height_m
|
||||
max_weight = preset.max_weight_kg
|
||||
|
||||
margin_side = _number(payload, "margin_side_m")
|
||||
margin_ceiling = _number(payload, "margin_ceiling_m")
|
||||
margin_side = 0.0 if margin_side is None else margin_side
|
||||
margin_ceiling = 0.0 if margin_ceiling is None else margin_ceiling
|
||||
|
||||
roll_diameter = _number(payload, "roll_diameter_mm")
|
||||
core_diameter = _number(payload, "core_diameter_mm")
|
||||
roll_width = _number(payload, "roll_width_m")
|
||||
roll_weight = _number(payload, "roll_weight_kg")
|
||||
product_length = _number(payload, "product_length_m")
|
||||
product_length = 0.0 if product_length is None else product_length
|
||||
except ValueError as error:
|
||||
return _invalid(str(error))
|
||||
|
||||
for field, value in (
|
||||
("length_m", length),
|
||||
("width_m", width),
|
||||
("height_m", height),
|
||||
("max_weight_kg", max_weight),
|
||||
):
|
||||
if value is None or value <= 0:
|
||||
return _invalid("must be greater than zero", field)
|
||||
|
||||
# The browser's getRollData() rejects falsy values but does not otherwise
|
||||
# validate roll inputs. Preserve that boundary rather than improve it here.
|
||||
for field, value in (
|
||||
("roll_diameter_mm", roll_diameter),
|
||||
("roll_width_m", roll_width),
|
||||
("roll_weight_kg", roll_weight),
|
||||
):
|
||||
if value is None or value == 0:
|
||||
return _invalid("required", field)
|
||||
if core_diameter is None:
|
||||
return _invalid("required", "core_diameter_mm")
|
||||
|
||||
diameter_m = roll_diameter / 1000
|
||||
core_m = core_diameter / 1000
|
||||
effective_width = width - margin_side
|
||||
effective_height = height - margin_ceiling
|
||||
|
||||
rolls_along_length = math.floor(length / roll_width)
|
||||
rolls_along_width = math.floor(effective_width / diameter_m)
|
||||
rolls_per_layer = rolls_along_length * rolls_along_width
|
||||
max_stack_height = math.floor(effective_height / diameter_m)
|
||||
max_by_geometry = rolls_per_layer * max_stack_height
|
||||
max_by_weight = math.floor(max_weight / roll_weight) if roll_weight > 0 else math.inf
|
||||
|
||||
volume_per_roll = (math.pi / 4) * (diameter_m**2 - core_m**2) * roll_width
|
||||
container_volume = length * width * height
|
||||
effective_volume = length * effective_width * effective_height
|
||||
max_by_volume = (
|
||||
math.floor(effective_volume / volume_per_roll)
|
||||
if volume_per_roll > 0
|
||||
else math.inf
|
||||
)
|
||||
|
||||
# The list order and strict comparison preserve JavaScript reduce() ties:
|
||||
# Weight wins over Volume, which wins over Geometry.
|
||||
scenarios = [
|
||||
{"name": "Weight", "rolls": max_by_weight, "type": "weight"},
|
||||
{"name": "Volume", "rolls": max_by_volume, "type": "volume"},
|
||||
{"name": "Geometry", "rolls": max_by_geometry, "type": "geometry"},
|
||||
]
|
||||
limiting = scenarios[0]
|
||||
for scenario in scenarios[1:]:
|
||||
if scenario["rolls"] < limiting["rolls"]:
|
||||
limiting = scenario
|
||||
|
||||
final_rolls = 0 if math.isinf(limiting["rolls"]) else limiting["rolls"]
|
||||
m2_per_roll = product_length * roll_width
|
||||
final_weight = final_rolls * roll_weight
|
||||
final_m2 = final_rolls * m2_per_roll
|
||||
for scenario in scenarios:
|
||||
scenario["weight"] = scenario["rolls"] * roll_weight
|
||||
scenario["m2"] = scenario["rolls"] * m2_per_roll
|
||||
scenario["utilization_weight"] = (
|
||||
(scenario["weight"] / max_weight) * 100 if max_weight > 0 else 0
|
||||
)
|
||||
scenario["rolls"] = _json_number(scenario["rolls"])
|
||||
scenario["weight"] = _json_number(scenario["weight"])
|
||||
scenario["m2"] = _json_number(scenario["m2"])
|
||||
|
||||
analysis = {
|
||||
"transport_preset": preset_key or None,
|
||||
"transport": {
|
||||
"length_m": length,
|
||||
"width_m": width,
|
||||
"height_m": height,
|
||||
"max_weight_kg": max_weight,
|
||||
},
|
||||
"roll": {
|
||||
"diameter_mm": roll_diameter,
|
||||
"core_diameter_mm": core_diameter,
|
||||
"width_m": roll_width,
|
||||
"weight_kg": roll_weight,
|
||||
"product_length_m": product_length,
|
||||
},
|
||||
"scenarios": scenarios,
|
||||
"limiting": {
|
||||
"name": limiting["name"],
|
||||
"type": limiting["type"],
|
||||
"rolls": _json_number(limiting["rolls"]),
|
||||
},
|
||||
"final_rolls": final_rolls,
|
||||
"final_weight_kg": final_weight,
|
||||
"final_m2": final_m2,
|
||||
"remaining_payload_kg": max_weight - final_weight,
|
||||
"utilization_weight": (final_weight / max_weight) * 100 if max_weight > 0 else 0,
|
||||
"utilization_volume": (
|
||||
(final_rolls * volume_per_roll / container_volume) * 100
|
||||
if container_volume > 0
|
||||
else 0
|
||||
),
|
||||
"layout": {
|
||||
"rolls_per_layer": rolls_per_layer,
|
||||
"rolls_along_length": rolls_along_length,
|
||||
"rolls_along_width": rolls_along_width,
|
||||
"max_stack_height": max_stack_height,
|
||||
"effective_width_m": effective_width,
|
||||
"effective_height_m": effective_height,
|
||||
"margin_side_m": margin_side,
|
||||
"margin_ceiling_m": margin_ceiling,
|
||||
},
|
||||
"container_volume_m3": container_volume,
|
||||
"effective_volume_m3": effective_volume,
|
||||
"volume_per_roll_m3": volume_per_roll,
|
||||
}
|
||||
return {"status": "success", "analysis": analysis}
|
||||
Reference in New Issue
Block a user