29 Commits
Author SHA1 Message Date
admin 0c18c86a47 Allow direct article resolution in machine max MCP 2026-09-26 13:52:08 +02:00
admin eca151a465 Reduce machine max MCP response size 2026-09-26 13:42:00 +02:00
admin 90c28fb2f5 Add machine-aware maximum product length 2026-09-26 12:25:04 +02:00
admin 3e13d24405 Add configurable production machine constraints 2026-09-26 10:47:53 +02:00
admin e6a7d4da72 Expose product length calculation through MCP 2026-09-25 21:21:20 +02:00
admin 580e9caa5e Harden MCP article and weight workflows 2026-09-25 16:34:49 +02:00
admin 1169df96a8 Add deterministic article search to MCP 2026-09-25 15:13:02 +02:00
admin 6f224a1831 Harden MCP transport preset routing 2026-09-25 12:04:01 +02:00
admin 78b00fedf8 Harden MCP tool composition 2026-09-24 21:00:48 +02:00
admin bbaaaf31c5 Add stdio MCP server for RollCalc 2026-09-24 13:58:55 +02:00
admin defbf20202 Refactor transport calculation into shared domain 2026-09-24 13:11:18 +02:00
admin fa3000b325 feat: add conversational RollCalc assistant 2026-08-29 22:37:56 +02:00
admin 3b44048250 Fix build info generation 2026-07-27 11:11:32 +02:00
admin fe267b8766 Merge version 0.4 2026-07-27 11:05:48 +02:00
admin c2e4f64d82 Bug fix 2026-07-07 16:50:10 +02:00
admin 7a27fb85c4 Extend Transport Optimizer 2026-07-07 16:40:30 +02:00
admin 4670666421 Restore diameter result and uncertainty display 2026-07-07 16:20:27 +02:00
admin dcca3fa042 Use password hashes for authentication 2026-07-07 16:16:50 +02:00
admin 8f64760add show build on website footer 2026-07-07 16:12:12 +02:00
admin ffa34815cd Remove legacy admin UI from RollCalc 2026-07-07 12:20:09 +02:00
martin ac6840c759 Merge pull request 'Chore/systemd service prep' (#1) from chore/systemd-service-prep into main
Reviewed-on: #1
2026-07-07 09:46:36 +02:00
admin 04f6f7ba6a added systemd/rollcalc.system tu Repo 2026-07-07 07:42:10 +00:00
admin 2459955acf Updated Roadmap and Agents files 2026-07-07 09:32:07 +02:00
admin b1797bc1bb changed host to 0.0.0.0 in app.py 2026-07-06 13:51:29 +00:00
admin a5a8bd5ea8 Switch to service deamon 2026-07-06 15:41:56 +02:00
admin c3a8a4a59a Merging to main 2026-07-06 12:41:41 +02:00
admin a6791cb038 Updated AGENTS.md and KNOWLEDE.MD 2026-07-03 13:53:12 +02:00
admin 924a18dbc8 Added README.md 2026-07-03 13:44:28 +02:00
admin d6d441fa69 Disclaimer re-added, added several documentation files for Codex 2026-07-03 09:48:01 +02:00
47 changed files with 11734 additions and 2410 deletions
+83
View File
@@ -0,0 +1,83 @@
# AGENTS.md
Guidance for coding agents working in this repository.
## Scope
This project is a small Flask application with a mostly client-side calculator UI. Treat it as an internal tool with sensitive access and product data assumptions.
Primary files:
- `app.py`: Flask app, HTTP Basic Auth, routes, access logging.
- `templates/roll_calculator.html`: active calculator UI, inline CSS, inline JavaScript, disclaimer gate, roll calculations, forklift check, load optimizer.
- `static/article-data.json`: active product/article data loaded by the UI.
- `static/config.json`: richer forklift/heavy-roll configuration, although the active template also contains inline config.
- `README.md`: developer-facing project overview.
- `PROJECT_KNOWLEDGE.md`: project-specific domain and implementation knowledge.
## Working Rules
- Before larger changes, present a concise plan.
- Do not change functional logic unless explicitly requested.
- Keep edits narrowly scoped to the requested task.
- Do not remove existing features or routes without explicit approval.
- Do not add Python packages without approval.
- Do not change existing APIs, route paths, data formats, or authentication behavior without approval.
- Do not manually edit generated product data unless the user explicitly asks for data maintenance.
- Do not add new article-data administration features to RollCalc; propose a separate app or integration boundary instead.
- Preserve login/access logging unless the user explicitly asks to change or remove it.
- Preserve the current Flask/vanilla JavaScript architecture unless the task is specifically a refactor.
- Prefer documenting observed behavior over assuming intended behavior.
## Planned Direction
- The previous admin UI/API has been removed from RollCalc.
- Do not reintroduce admin screens or admin APIs unless explicitly requested as a temporary measure.
- Keep login/access logging in RollCalc.
- Administration and maintenance of `article-data.json` should move to a separate application.
- Treat article-data administration as out of scope for RollCalc.
## Code Style
- Python should follow PEP 8.
- JavaScript should use modern ES6 style where it fits the existing code.
- Keep comments sparse and useful.
- Use clear names over explanatory comments where possible.
- Avoid broad refactors in `templates/roll_calculator.html`; it is large and risk-prone.
## Validation Expectations
For backend or template changes:
- Start the app locally when practical.
- If port `5000` is occupied, use:
```bash
flask --app app run --host 127.0.0.1 --port 5001
```
- Check authenticated endpoints with Basic Auth when practical.
- For pure documentation changes, no runtime validation is required.
Known local credential currently present in `app.py`:
```text
mtazl / rollcalc
```
These credentials are hardcoded and are a known risk; do not introduce more secrets.
## Important Technical Constraints
- `app.py` currently hardcodes users and passwords.
- Access logging writes to `access_log.json` by reading and rewriting the whole file.
- The active UI logic is largely inline in `templates/roll_calculator.html`.
- Several `static/*.js` files appear to be older, alternative, or integration modules. Confirm script inclusion before modifying them.
- The previous admin template and backend admin route have been removed; do not rebuild them by default.
- The disclaimer confirmation is client-side only.
## Safety Notes
- Treat `static/article-data.json` as generated ERP-derived data.
- The project contains product/material assumptions. Do not adjust formulas, thresholds, or category rules without a specific request.
- The calculator output is advisory and requires plausibility checks before use.
+312
View File
@@ -0,0 +1,312 @@
# PROJECT_KNOWLEDGE.md
Project-specific technical and domain knowledge for RollCalcPython.
## Purpose
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.
## Current Architecture
Backend:
- `app.py` creates the Flask app.
- HTTP Basic Auth is implemented with `Flask-HTTPAuth`.
- Users are currently configured in `BETA_USERS` with Werkzeug password hashes.
- `/` renders the active calculator template.
- `/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`.
Frontend:
- `templates/roll_calculator.html` is the active application page.
- The page contains most CSS and JavaScript inline.
- `window.ARTICLE_DATA` is loaded from `/static/article-data.json`.
- `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
Build/deployment metadata is read from:
```text
build_info.json
```
Expected fields:
- `version`
- `branch`
- `commit`
- `timestamp`
If `build_info.json` is missing, malformed, or does not contain a usable value, RollCalc falls back to `unknown` for that value and continues serving the site.
The data is loaded centrally in `app.py` and made available to every template as `build_info`.
## Authentication
RollCalc uses HTTP Basic Auth. User entries in `BETA_USERS` have this shape:
```python
"username": {
"password_hash": "..."
}
```
Password verification uses `werkzeug.security.check_password_hash`. New hashes or config snippets can be generated with:
```bash
python scripts/manage_users.py username
```
Passwords and hashes must not be logged.
## Roll Geometry
Calculations assume an ideal cylindrical winding.
Assumptions:
- Constant material thickness.
- No compression.
- No air gaps.
- Roll geometry is derived mathematically, not physically measured in the app.
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)
```
Variables:
- `D`: roll diameter in mm.
- `d`: core diameter in mm.
- `L`: product length in m.
- `t`: material thickness in mm.
When a tolerance/stddev value is present, the UI also displays a `-2σ` and `+2σ` diameter range.
## Product Length and Extrapolation
Product length from a known diameter is calculated by rearranging the diameter formula:
```text
L = (pi / (4 * t)) * (D^2 - d^2) / 1000
```
The extrapolation tab estimates a new diameter from a known roll diameter/length pair:
```text
D1 = sqrt(d^2 + (L1 / L0) * (D0^2 - d^2))
```
Variables:
- `D0`: known/measured roll diameter.
- `L0`: known/measured roll length.
- `L1`: target length.
- `D1`: extrapolated roll diameter.
## Roll Weight
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
```
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:
```text
static/article-data.json
```
The UI expects article records with fields such as:
- `nr`
- `name`
- `thickness`
- `thickness_stddev`
- `area_weight`
- `area_weight_stddev`
- `core_type`
Observed additional fields include min/max/count values for material thickness and area weight.
Important data rule:
- Article data is generated from ERP-derived sources.
- These values should not be manually changed unless the task is explicitly data maintenance.
Known domain caveat from existing project notes:
- For articles in the groups Secugrid, Combigrid, Carbofol MF/MF, Carbofol F/F, Carbofol s/F, and Carbofol BF/s, the thickness from `article-data.json` must not be used without domain validation.
## Forklift and Heavy-Roll Rules
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:
- Applies to category `bentofix`.
- Warning threshold: `1700 kg`.
- Hard limit: `2750 kg`.
- Minimum core outer diameter: `170 mm`.
There is also a richer JSON configuration in:
```text
static/config.json
```
That file includes localized messages and additional equipment requirements. Before changing forklift behavior, verify whether the active inline config or the static JSON should be authoritative.
## Load Optimizer
`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.
It uses displayed roll data from the Direct Calculation tab:
- Roll diameter.
- Core diameter.
- Roll width.
- Roll weight.
- Product length.
- Square meters per roll.
It supports transport presets:
- 20ft Container.
- 40ft Container.
- 40ft High Cube.
- LKW Sattelzug.
- LKW Tandem.
It calculates limiting scenarios by:
- Weight.
- Volume.
- Geometry.
It also draws a side-view SVG visualization.
## Disclaimer Gate
The active calculator page includes a disclaimer modal shown after Basic Auth login and before calculator use.
The modal:
- Contains German and English disclaimer text.
- Requires checkbox confirmation.
- Enables the confirmation button only after the checkbox is selected.
- Hides the overlay after confirmation.
This is client-side only. It is not persisted, logged, or enforced server-side.
## Admin Area State
RollCalc no longer contains an admin UI or admin API. The previous `/admin/logs` route and `templates/admin.html` have been removed.
Login/access logging remains part of RollCalc, but log viewing and article-data administration should not be implemented inside this app. Maintenance of `article-data.json` is planned for a separate application.
## Known Technical Risks
- Password hashes are currently configured in `app.py`; this should eventually move to a protected external configuration or secrets mechanism.
- Basic Auth only; no sessions or role framework beyond the user dictionary.
- `access_log.json` is rewritten on every logged request and is not concurrency-safe.
- No log rotation or retention policy is implemented.
- 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.
- 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.
+596
View File
@@ -0,0 +1,596 @@
# RollCalcPython
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.
## Runtime Stack
- Python 3
- Flask 2.3.3
- 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`.
## Local Setup
Create and activate a virtual environment:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
Start with the hardcoded settings in `app.py`:
```bash
python app.py
```
By default this attempts to bind to:
```text
http://localhost:5000
```
If port `5000` is already occupied, start through Flask's CLI without changing files:
```bash
flask --app app run --host 127.0.0.1 --port 5001
```
The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.py` with Werkzeug password hashes.
## Project Layout
```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
├── access_log.json
├── config.json
├── article-data_.json
├── fix_article_data.py
├── service-worker.js
├── templates/
│ └── roll_calculator.html
├── tests/
│ └── test_pdf_report.py
├── static/
│ ├── article-data.json
│ ├── config.json
│ ├── service-worker.js
│ ├── stddev_calculator.js
│ ├── direct_calc_handler.js
│ ├── rollcalc_v14.js
│ ├── rollcalc_improvements.js
│ ├── rollcalc-improvements.js
│ ├── rollcalc_stddev_ranges.js
│ └── rollcalc_stddev_integration.js
└── docs/
├── DEPLOYMENT_GUIDE.md
├── QOL_UPDATE_SUMMARY.md
├── STDDEV_IMPLEMENTATION_CHECKLIST.md
└── STDDEV_RANGES_DOCS.md
```
## Backend
`app.py` owns the Flask application and authentication.
Implemented routes:
| Route | Methods | Auth | Purpose |
| --- | --- | --- | --- |
| `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. |
| `/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
python scripts/manage_users.py username
python scripts/manage_users.py username --json
```
For non-interactive local maintenance only:
```bash
python scripts/manage_users.py username --password 'new-password'
```
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`.
The template contains:
- Page layout and all main CSS.
- A disclaimer modal shown after Basic Auth login and before calculator use.
- Footer build metadata from `build_info`.
- 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-diameter request/response rendering.
- Extrapolation calculations.
- Forklift/weight warnings.
- Load optimizer UI and calculations.
Most of the currently active JavaScript is inline in this template. Several files under `static/` appear to be older, alternative, or integration-oriented modules and should be checked before assuming they are active.
## Main Functional Areas
### Disclaimer Gate
The calculator page displays a modal disclaimer before use. The modal:
- Shows German and English disclaimer text.
- Requires a checkbox confirmation.
- Keeps the confirmation button disabled until checked.
- Hides the overlay after confirmation.
This is implemented in `templates/roll_calculator.html` and is client-side only.
### Article Data Loading
`templates/roll_calculator.html` fetches:
```text
/static/article-data.json
```
The loaded array is assigned to `window.ARTICLE_DATA` and used to populate article datalists for direct calculation and extrapolation.
Known article fields used by the UI include:
- `nr`
- `name`
- `thickness`
- `thickness_stddev`
- `area_weight`
- `area_weight_stddev`
- `core_type`
The active `static/article-data.json` currently contains product records with additional min/max/count statistics.
### Direct Calculation
The direct calculation tab supports multiple modes via mode buttons:
- Roll diameter from core diameter, material thickness, and product length.
- Product length from core diameter, material thickness, and roll diameter.
- Product length for a target diameter.
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)
```
Where:
- `D` is roll diameter in mm.
- `d` is core diameter in mm.
- `L` is product length in m.
- `t` is material thickness in mm.
If a tolerance/stddev value is present, the UI also displays a `-2σ` and `+2σ` diameter range.
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:
```text
D1 = sqrt(d^2 + (L1 / L0) * (D0^2 - d^2))
```
Where:
- `d` is core diameter.
- `D0` is measured/current diameter.
- `L0` is measured/current length.
- `L1` is target length.
- `D1` is the calculated new diameter.
### Forklift Check
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:
- Warning threshold: `1700 kg`
- Hard limit: `2750 kg`
- Minimum core outer diameter requirement: `170 mm`
There is also a richer `static/config.json` with localized messages and more detailed requirements.
### Load Optimizer
`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.
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:
- 20ft Container
- 40ft Container
- 40ft High Cube
- LKW Sattelzug
- LKW Tandem
The optimizer calculates limiting scenarios by:
- Weight
- Volume
- Geometry
It also renders a side-view SVG visualization of the loading arrangement.
### Standard Deviation Modules
There are standalone/static modules for standard deviation logic:
- `static/stddev_calculator.js`
- `static/rollcalc_stddev_ranges.js`
- `static/rollcalc_stddev_integration.js`
These provide or describe range calculations for material thickness, area weight, roll diameter, and roll weight. Check actual script inclusion before treating them as active in production, because the current template already contains inline stddev/tolerance behavior.
### Admin UI
RollCalc no longer contains an admin UI or admin API. Login/access logging remains in the backend, but logs are not exposed through a RollCalc admin screen.
Administration and maintenance of `article-data.json` is planned for a separate application. RollCalc should continue to consume `static/article-data.json` read-only.
## Static Assets and Data Files
### `static/article-data.json`
Primary product/article dataset used by the active calculator UI.
### `static/config.json`
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`.
Important implementation detail: each logged access reads and rewrites the entire JSON file. This is simple but not concurrency-safe and can become inefficient as the file grows.
### `build_info.json`
Deployment/build metadata displayed in the site footer.
Expected fields:
- `version`
- `branch`
- `commit`
- `timestamp`
`app.py` loads this file centrally at startup and exposes it to all templates as `build_info`. If the file is missing, invalid, or a field is empty, the affected values fall back to `unknown` and the website continues to work.
### `fix_article_data.py`
Utility script that updates relative frontend fetch/register paths to Flask-style `/static/...` paths and checks that key static files exist.
## Development Notes
- The codebase is currently closer to a single-page static calculator wrapped by Flask than to a conventional Flask MVC app.
- The active calculator logic is concentrated in `templates/roll_calculator.html`.
- There are duplicated or legacy-looking files with similar names. Before editing JavaScript under `static/`, confirm it is actually referenced by the active template.
- The documentation under `docs/` contains deployment and feature notes, but some filenames and route assumptions may not match the current app exactly.
- The Flask dev server is used for local development only. Production should use a WSGI server.
- Use `scripts/manage_users.py` to create password hashes when adding or rotating Basic Auth users.
## Known Risks and Maintenance Items
- Password hashes are currently configured in `app.py`; user configuration should eventually move to environment variables, a protected config file, or a secrets manager.
- `access_log.json` is not safe for concurrent writes.
- `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.
- 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.
+17
View File
@@ -0,0 +1,17 @@
## Version 0.3
- Mehrsprachigkeit
- Filter der Artikelauswahl auf Basis der gewählten Produktgruppe: Secutex, Carbofol, Bentofix, Secumat, Secudrain
- PDF-Export
## Version 0.4
- Benutzermanagement auf sichere Methode umstellen, keine Klartext-Passworte mehr
- Login-/Zugriffslogging beibehalten und robuster machen
- Bestehenden Admin-UI-/Admin-API-Teil aus RollCalc entfernen
- Pflege der `article-data.json` in eine eigenständige Administrations-App auslagern
- Produktionsdaten perspektivisch aus SQL per Query direkt beziehen
## Version 0.5
- Schnittstelle bzw. Import-/Deployment-Prozess zwischen externer Artikelverwaltungs-App und RollCalc definieren
- Rollen-/Berechtigungskonzept für Zugriff auf RollCalc und externe Artikelverwaltung klären
+126
View File
@@ -537,5 +537,131 @@
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:07:56.334163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:08:38.097844",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:39:44.284544",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:41:57.673023",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:42:19.877155",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T10:34:02.710459",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:14:58.113794",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:20.071507",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:32.538576",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T11:44:53.524315",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T11:47:36.702455",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T12:18:47.866971",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T14:46:22.335316",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:13:42.026433",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:19:41.488752",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:34:58.280178",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:36:56.896657",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-07T16:41:46.568545",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
}
]
-849
View File
@@ -1,849 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Roll Calculator Admin</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f4f6f9;
color: #333;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #003366 0%, #0066cc 100%);
color: white;
padding: 20px 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 20px; }
.container {
max-width: 1200px;
margin: 30px auto;
padding: 0 20px;
}
.info-box {
background: #fffbea;
border-left: 4px solid #f0b429;
padding: 16px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 13px;
color: #666;
line-height: 1.6;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #e0e8f0;
flex-wrap: wrap;
}
.tab-btn {
padding: 12px 20px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #666;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tab-btn.active {
color: #0066cc;
border-bottom-color: #0066cc;
font-weight: 700;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
padding: 24px;
margin-bottom: 20px;
}
.card h2 {
font-size: 18px;
margin-bottom: 8px;
color: #003366;
}
.card > p {
font-size: 12px;
color: #666;
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 12px;
font-weight: 600;
color: #555;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.form-group input, .form-group textarea, .form-group select {
padding: 9px 11px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
font-family: 'Segoe UI', Arial, sans-serif;
transition: border 0.2s;
}
.form-group textarea {
font-family: 'Courier New', monospace;
resize: vertical;
min-height: 300px;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0,102,204,0.1);
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary {
background: #0066cc;
color: white;
}
.btn-primary:hover { background: #0052a3; box-shadow: 0 2px 6px rgba(0,102,204,0.3); }
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover { background: #d0d0d0; }
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover { background: #c82333; }
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover { background: #218838; }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table th {
background: #f5f5f5;
padding: 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #0066cc;
color: #333;
}
table td {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
}
table tr:hover { background: #f9f9f9; }
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 13px;
display: none;
}
.alert.show { display: block; }
.alert-success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.alert-error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert-info {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-buttons button {
padding: 6px 12px;
font-size: 11px;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.edit-btn {
background: #0066cc;
color: white;
}
.edit-btn:hover { background: #0052a3; }
.delete-btn {
background: #dc3545;
color: white;
}
.delete-btn:hover { background: #c82333; }
.password-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.password-box {
background: white;
border-radius: 8px;
padding: 32px;
max-width: 400px;
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
text-align: center;
}
.password-box h2 { margin-bottom: 20px; color: #003366; }
.password-box input {
width: 100%;
padding: 11px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
margin-bottom: 16px;
}
.password-box .btn { width: 100%; margin-bottom: 10px; }
.file-input {
display: none;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat {
background: linear-gradient(135deg, #e8f4fd 0%, #d0e8fa 100%);
border: 1px solid #0066cc;
border-radius: 6px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #003366;
}
.stat-label {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 5000;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: white;
border-radius: 8px;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
padding: 30px;
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
width: 100%;
}
.modal h2 {
color: #003366;
margin-bottom: 20px;
}
.modal-buttons {
display: flex;
gap: 10px;
margin-top: 30px;
justify-content: flex-end;
}
</style>
</head>
<body>
<!-- PASSWORD OVERLAY -->
<div id="passwordOverlay" class="password-overlay">
<div class="password-box">
<h2>🔐 Roll Calculator Admin</h2>
<p style="font-size:13px;color:#666;margin-bottom:20px;">Geben Sie das Admin-Passwort ein</p>
<input type="password" id="passwordInput" placeholder="Passwort" autofocus>
<button class="btn btn-primary" onclick="checkPassword()">🔓 Anmelden</button>
<p style="font-size:11px;color:#999;margin-top:10px;">Standard: admin123</p>
</div>
</div>
<!-- EDIT MODAL -->
<div id="editModal" class="modal-overlay">
<div class="modal">
<h2>📝 Artikel bearbeiten</h2>
<div class="form-row">
<div class="form-group">
<label>Artikel-Nr. (nicht änderbar)</label>
<input type="text" id="editNr" disabled style="background:#f5f5f5;">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Name</label>
<input type="text" id="editName">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Produktdicke (mm)</label>
<input type="number" id="editThickness" step="0.001" min="0">
</div>
<div class="form-group">
<label>Area Weight (g/m²)</label>
<input type="number" id="editAreaWeight" step="1" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Gewicht pro Rolle (kg)</label>
<input type="number" id="editWeightPerRoll" step="0.1" min="0">
</div>
<div class="form-group">
<label>Breite (m)</label>
<input type="number" id="editWidth" step="0.01" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Preis (€)</label>
<input type="number" id="editPrice" step="0.01" min="0">
</div>
<div class="form-group">
<label>Typ</label>
<input type="text" id="editType" placeholder="z.B. Geotextil, Kunststoff">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Farbe</label>
<input type="text" id="editColor" placeholder="z.B. Schwarz, Weiß">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Beschreibung</label>
<textarea id="editDescription" placeholder="Zusätzliche Informationen..."></textarea>
</div>
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" onclick="closeEditModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="saveEditedArticle()">💾 Speichern</button>
</div>
</div>
</div>
<!-- MAIN APP -->
<div id="mainApp" style="display:none;">
<div class="header">
<h1>🛠️ Roll Calculator Admin</h1>
<button class="btn btn-secondary" onclick="logout()">Abmelden</button>
</div>
<div class="container">
<div class="info-box">
💡 Tipp: Alle Änderungen werden im Browser gespeichert. Mit "Download" kannst du die neuen JSON-Dateien herunterladen und auf deinen Server kopieren.
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('articles')">📄 Artikel-Daten</button>
<button class="tab-btn" onclick="switchTab('config')">⚙️ Konfiguration</button>
<button class="tab-btn" onclick="switchTab('tools')">🔧 Tools</button>
</div>
<!-- TAB: ARTICLES -->
<div id="articles" class="tab-content active">
<div class="card">
<h2>Artikel verwalten</h2>
<p>Artikel hinzufügen, bearbeiten oder löschen</p>
<div id="articleAlert" class="alert"></div>
<div class="stats">
<div class="stat">
<div class="stat-value" id="articleCount">0</div>
<div class="stat-label">Artikel</div>
</div>
</div>
<h3 style="font-size:14px;margin-bottom:12px;margin-top:20px;">Neuer Artikel</h3>
<div class="form-row">
<div class="form-group">
<label>Artikel-Nr. *</label>
<input type="text" id="artNr" placeholder="z.B. 180305">
</div>
<div class="form-group">
<label>Name *</label>
<input type="text" id="artName" placeholder="z.B. Bfix NSP 5300">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Produktdicke (mm) *</label>
<input type="number" id="artThickness" placeholder="z.B. 6.918" step="0.001" min="0">
</div>
<div class="form-group">
<label>Area Weight (g/m²)</label>
<input type="number" id="artAreaWeight" placeholder="z.B. 5300" step="1" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Gewicht pro Rolle (kg)</label>
<input type="number" id="artWeightPerRoll" placeholder="z.B. 25" step="0.1" min="0">
</div>
<div class="form-group">
<label>Breite (m)</label>
<input type="number" id="artWidth" placeholder="z.B. 5" step="0.01" min="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Preis (€)</label>
<input type="number" id="artPrice" placeholder="z.B. 250.00" step="0.01" min="0">
</div>
<div class="form-group">
<label>Typ</label>
<input type="text" id="artType" placeholder="z.B. Geotextil">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Farbe</label>
<input type="text" id="artColor" placeholder="z.B. Schwarz">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Beschreibung</label>
<textarea id="artDescription" placeholder="Zusätzliche Informationen..."></textarea>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="addArticle()">➕ Hinzufügen</button>
<button class="btn btn-secondary" onclick="clearArticleForm()">Zurücksetzen</button>
</div>
<h3 style="font-size:14px;margin-bottom:12px;margin-top:30px;">Artikel-Liste</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Nr.</th>
<th>Name</th>
<th>Dicke (mm)</th>
<th>Breite (m)</th>
<th>Preis (€)</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="articleTableBody">
<tr><td colspan="6" style="text-align:center;color:#999;">Keine Artikel</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB: CONFIG -->
<div id="config" class="tab-content">
<div class="card">
<h2>Konfiguration (config.json)</h2>
<p>Gabelstapler-Regeln und Schwellenwerte - JSON direkt bearbeiten</p>
<div id="configAlert" class="alert"></div>
<textarea id="configJson" placeholder="config.json wird hier angezeigt..."></textarea>
<div class="button-group">
<button class="btn btn-primary" onclick="saveConfigJson()">💾 Aktualisieren</button>
<button class="btn btn-secondary" onclick="resetConfigJson()">🔄 Zurücksetzen</button>
</div>
<p style="font-size:11px;color:#999;margin-top:16px;">
💡 JSON wird automatisch validiert. Im Fehlerfall siehst du eine Fehlermeldung.
</p>
</div>
</div>
<!-- TAB: TOOLS -->
<div id="tools" class="tab-content">
<div class="card">
<h2>📥 Dateien verwalten</h2>
<p>Herunterladen, hochladen oder zurücksetzen</p>
<div id="toolsAlert" class="alert"></div>
<h3 style="font-size:13px;margin-bottom:12px;">Download</h3>
<div class="button-group">
<button class="btn btn-success" onclick="downloadArticleData()">📥 article-data.json</button>
<button class="btn btn-success" onclick="downloadConfig()">📥 config.json</button>
<button class="btn btn-success" onclick="downloadAll()">📦 Alle Dateien (ZIP)</button>
</div>
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">Upload</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Dateien hochladen und überschreiben:</p>
<div class="button-group">
<button class="btn btn-secondary" onclick="document.getElementById('uploadFile').click()">📤 JSON-Datei hochladen</button>
</div>
<input type="file" id="uploadFile" class="file-input" accept=".json" onchange="handleFileUpload(event)">
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">Daten</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Alle bearbeiteten Daten als ein JSON exportieren:</p>
<button class="btn btn-primary" onclick="exportAllAsJson()">💾 Alles exportieren</button>
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">⚠️ Vorsicht</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Bearbeitete Daten zurücksetzen (Achtung: nicht rückgängig!):</p>
<button class="btn btn-danger" onclick="if(confirm('Wirklich auf Original zurücksetzen?')) resetAll()">🔄 Auf Standardwerte zurücksetzen</button>
</div>
</div>
</div>
</div>
<script>
const ADMIN_PASSWORD = 'admin123';
let articlesData = [];
let configData = {};
let currentEditingArticle = null;
document.addEventListener('DOMContentLoaded', () => {
loadDataFromServer();
document.getElementById('passwordInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') checkPassword();
});
});
function loadDataFromServer() {
Promise.all([
fetch('article-data.json').then(r => r.json()).catch(() => []),
fetch('config.json').then(r => r.json()).catch(() => ({}))
]).then(([articles, config]) => {
articlesData = articles || [];
configData = config || {};
console.log('✅ Daten geladen:', articlesData.length, 'Artikel');
});
}
function checkPassword() {
const pwd = document.getElementById('passwordInput').value;
if (pwd === ADMIN_PASSWORD) {
document.getElementById('passwordOverlay').style.display = 'none';
document.getElementById('mainApp').style.display = 'block';
refreshUI();
} else {
alert('❌ Falsches Passwort!');
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
}
function logout() {
document.getElementById('mainApp').style.display = 'none';
document.getElementById('passwordOverlay').style.display = 'flex';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById(tabName).classList.add('active');
event.target.classList.add('active');
if (tabName === 'config') loadConfigJson();
if (tabName === 'articles') refreshArticleTable();
}
function showAlert(containerId, message, type, duration = 4000) {
const alert = document.getElementById(containerId);
alert.className = `alert show alert-${type}`;
alert.textContent = message;
if (duration) setTimeout(() => { alert.classList.remove('show'); }, duration);
}
function refreshUI() {
refreshArticleTable();
loadConfigJson();
updateStats();
}
function updateStats() {
document.getElementById('articleCount').textContent = articlesData.length;
}
function openEditModal(nr) {
const article = articlesData.find(a => a.nr === nr);
if (!article) return;
currentEditingArticle = article;
document.getElementById('editNr').value = article.nr || '';
document.getElementById('editName').value = article.name || '';
document.getElementById('editThickness').value = article.thickness || '';
document.getElementById('editAreaWeight').value = article.area_weight || '';
document.getElementById('editWeightPerRoll').value = article.weight_per_roll || '';
document.getElementById('editWidth').value = article.width || '';
document.getElementById('editPrice').value = article.price || '';
document.getElementById('editType').value = article.type || '';
document.getElementById('editColor').value = article.color || '';
document.getElementById('editDescription').value = article.description || '';
document.getElementById('editModal').classList.add('active');
}
function closeEditModal() {
document.getElementById('editModal').classList.remove('active');
currentEditingArticle = null;
}
function saveEditedArticle() {
if (!currentEditingArticle) return;
currentEditingArticle.name = document.getElementById('editName').value.trim();
currentEditingArticle.thickness = parseFloat(document.getElementById('editThickness').value) || 0;
currentEditingArticle.area_weight = parseFloat(document.getElementById('editAreaWeight').value) || 0;
currentEditingArticle.weight_per_roll = parseFloat(document.getElementById('editWeightPerRoll').value) || 0;
currentEditingArticle.width = parseFloat(document.getElementById('editWidth').value) || 0;
currentEditingArticle.price = parseFloat(document.getElementById('editPrice').value) || 0;
currentEditingArticle.type = document.getElementById('editType').value.trim();
currentEditingArticle.color = document.getElementById('editColor').value.trim();
currentEditingArticle.description = document.getElementById('editDescription').value.trim();
closeEditModal();
showAlert('articleAlert', '✅ Artikel aktualisiert!', 'success');
refreshArticleTable();
}
function addArticle() {
const nr = document.getElementById('artNr').value.trim();
const name = document.getElementById('artName').value.trim();
const thickness = parseFloat(document.getElementById('artThickness').value) || 0;
if (!nr || !name || thickness === 0) {
showAlert('articleAlert', '❌ Artikel-Nr., Name und Dicke sind erforderlich!', 'error');
return;
}
if (articlesData.some(a => a.nr === nr)) {
showAlert('articleAlert', '❌ Artikel-Nr. existiert bereits!', 'error');
return;
}
const newArticle = {
nr,
name,
thickness,
area_weight: parseFloat(document.getElementById('artAreaWeight').value) || 0,
weight_per_roll: parseFloat(document.getElementById('artWeightPerRoll').value) || 0,
width: parseFloat(document.getElementById('artWidth').value) || 0,
price: parseFloat(document.getElementById('artPrice').value) || 0,
type: document.getElementById('artType').value.trim(),
color: document.getElementById('artColor').value.trim(),
description: document.getElementById('artDescription').value.trim()
};
articlesData.push(newArticle);
showAlert('articleAlert', '✅ Artikel hinzugefügt!', 'success');
clearArticleForm();
refreshArticleTable();
updateStats();
}
function deleteArticle(nr) {
if (confirm('Artikel ' + nr + ' wirklich löschen?')) {
articlesData = articlesData.filter(a => a.nr !== nr);
showAlert('articleAlert', '✅ Artikel gelöscht!', 'success');
refreshArticleTable();
updateStats();
}
}
function refreshArticleTable() {
const tbody = document.getElementById('articleTableBody');
if (articlesData.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#999;">Keine Artikel</td></tr>';
return;
}
tbody.innerHTML = articlesData.map(a => `
<tr>
<td><strong>${a.nr}</strong></td>
<td>${a.name}</td>
<td>${a.thickness.toFixed(3)}</td>
<td>${a.width ? a.width.toFixed(2) : '—'}</td>
<td>${a.price ? a.price.toFixed(2) + ' €' : '—'}</td>
<td>
<div class="action-buttons">
<button class="edit-btn" onclick="openEditModal('${a.nr}')">✎ Bearbeiten</button>
<button class="delete-btn" onclick="deleteArticle('${a.nr}')">✕ Löschen</button>
</div>
</td>
</tr>
`).join('');
}
function clearArticleForm() {
document.getElementById('artNr').value = '';
document.getElementById('artName').value = '';
document.getElementById('artThickness').value = '';
document.getElementById('artAreaWeight').value = '';
document.getElementById('artWeightPerRoll').value = '';
document.getElementById('artWidth').value = '';
document.getElementById('artPrice').value = '';
document.getElementById('artType').value = '';
document.getElementById('artColor').value = '';
document.getElementById('artDescription').value = '';
}
function loadConfigJson() {
document.getElementById('configJson').value = JSON.stringify(configData, null, 2);
}
function saveConfigJson() {
try {
configData = JSON.parse(document.getElementById('configJson').value);
showAlert('configAlert', '✅ Konfiguration aktualisiert!', 'success');
} catch (e) {
showAlert('configAlert', '❌ Ungültiges JSON: ' + e.message, 'error', 0);
}
}
function resetConfigJson() {
loadConfigJson();
showAlert('configAlert', '🔄 Zurückgesetzt', 'info');
}
function downloadFile(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadArticleData() {
downloadFile(articlesData, 'article-data.json');
showAlert('toolsAlert', '✅ article-data.json heruntergeladen', 'success');
}
function downloadConfig() {
downloadFile(configData, 'config.json');
showAlert('toolsAlert', '✅ config.json heruntergeladen', 'success');
}
function downloadAll() {
const allData = { articles: articlesData, config: configData, exported_at: new Date().toISOString() };
downloadFile(allData, 'roll-calculator-backup-' + new Date().toISOString().slice(0,10) + '.json');
showAlert('toolsAlert', '✅ Alle Daten heruntergeladen', 'success');
}
function exportAllAsJson() {
downloadAll();
}
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (data.articles) articlesData = data.articles;
if (data.config) configData = data.config;
if (data.articleData) articlesData = data.articleData;
showAlert('toolsAlert', '✅ Datei hochgeladen und geladen!', 'success');
refreshUI();
} catch (err) {
showAlert('toolsAlert', '❌ Fehler beim Laden der Datei: ' + err.message, 'error', 0);
}
};
reader.readAsText(file);
event.target.value = '';
}
function resetAll() {
articlesData = [];
configData = {};
showAlert('toolsAlert', '🔄 Auf Standardwerte zurückgesetzt', 'success');
refreshUI();
}
</script>
</body>
</html>
-694
View File
@@ -1,694 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Roll Calculator Admin - Einfach</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f4f6f9;
color: #333;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #003366 0%, #0066cc 100%);
color: white;
padding: 20px 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 20px; }
.container {
max-width: 1200px;
margin: 30px auto;
padding: 0 20px;
}
.info-box {
background: #fffbea;
border-left: 4px solid #f0b429;
padding: 16px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 13px;
color: #666;
line-height: 1.6;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #e0e8f0;
flex-wrap: wrap;
}
.tab-btn {
padding: 12px 20px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #666;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tab-btn.active {
color: #0066cc;
border-bottom-color: #0066cc;
font-weight: 700;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
padding: 24px;
margin-bottom: 20px;
}
.card h2 {
font-size: 18px;
margin-bottom: 8px;
color: #003366;
}
.card > p {
font-size: 12px;
color: #666;
margin-bottom: 20px;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 12px;
font-weight: 600;
color: #555;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.form-group input, .form-group textarea {
padding: 9px 11px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
font-family: 'Segoe UI', Arial, sans-serif;
transition: border 0.2s;
}
.form-group textarea {
font-family: 'Courier New', monospace;
resize: vertical;
min-height: 300px;
}
.form-group input:focus, .form-group textarea:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0,102,204,0.1);
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary {
background: #0066cc;
color: white;
}
.btn-primary:hover { background: #0052a3; box-shadow: 0 2px 6px rgba(0,102,204,0.3); }
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover { background: #d0d0d0; }
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover { background: #c82333; }
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover { background: #218838; }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table th {
background: #f5f5f5;
padding: 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #0066cc;
color: #333;
}
table td {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
}
table tr:hover { background: #f9f9f9; }
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 13px;
display: none;
}
.alert.show { display: block; }
.alert-success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.alert-error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert-info {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.action-buttons {
display: flex;
gap: 6px;
}
.action-buttons button {
padding: 6px 12px;
font-size: 11px;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
}
.edit-btn {
background: #0066cc;
color: white;
}
.edit-btn:hover { background: #0052a3; }
.delete-btn {
background: #dc3545;
color: white;
}
.delete-btn:hover { background: #c82333; }
.password-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.password-box {
background: white;
border-radius: 8px;
padding: 32px;
max-width: 400px;
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
text-align: center;
}
.password-box h2 { margin-bottom: 20px; color: #003366; }
.password-box input {
width: 100%;
padding: 11px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
margin-bottom: 16px;
}
.password-box .btn { width: 100%; margin-bottom: 10px; }
.file-input {
display: none;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat {
background: linear-gradient(135deg, #e8f4fd 0%, #d0e8fa 100%);
border: 1px solid #0066cc;
border-radius: 6px;
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #003366;
}
.stat-label {
font-size: 12px;
color: #666;
margin-top: 4px;
}
</style>
</head>
<body>
<!-- PASSWORD OVERLAY -->
<div id="passwordOverlay" class="password-overlay">
<div class="password-box">
<h2>🔐 Roll Calculator Admin</h2>
<p style="font-size:13px;color:#666;margin-bottom:20px;">Geben Sie das Admin-Passwort ein</p>
<input type="password" id="passwordInput" placeholder="Passwort" autofocus>
<button class="btn btn-primary" onclick="checkPassword()">🔓 Anmelden</button>
<p style="font-size:11px;color:#999;margin-top:10px;">Standard: admin123</p>
</div>
</div>
<!-- MAIN APP -->
<div id="mainApp" style="display:none;">
<div class="header">
<h1>🛠️ Roll Calculator Admin</h1>
<button class="btn btn-secondary" onclick="logout()">Abmelden</button>
</div>
<div class="container">
<div class="info-box">
💡 Tipp: Alle Änderungen werden im Browser gespeichert. Mit "Download" kannst du die neuen JSON-Dateien herunterladen und auf deinen Server kopieren.
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('articles')">📄 Artikel-Daten</button>
<button class="tab-btn" onclick="switchTab('config')">⚙️ Konfiguration</button>
<button class="tab-btn" onclick="switchTab('tools')">🔧 Tools</button>
</div>
<!-- TAB: ARTICLES -->
<div id="articles" class="tab-content active">
<div class="card">
<h2>Artikel verwalten</h2>
<p>Artikel hinzufügen, bearbeiten oder löschen</p>
<div id="articleAlert" class="alert"></div>
<div class="stats">
<div class="stat">
<div class="stat-value" id="articleCount">0</div>
<div class="stat-label">Artikel</div>
</div>
</div>
<h3 style="font-size:14px;margin-bottom:12px;margin-top:20px;">Neuer Artikel</h3>
<div class="form-row">
<div class="form-group">
<label>Artikel-Nr.</label>
<input type="text" id="artNr" placeholder="z.B. 180305">
</div>
<div class="form-group">
<label>Name</label>
<input type="text" id="artName" placeholder="z.B. Bfix NSP 5300, 5,00 x 40 m">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Produktdicke (mm)</label>
<input type="number" id="artThickness" placeholder="z.B. 6.918" step="0.001" min="0">
</div>
<div class="form-group">
<label>Area Weight (g/m²) - Optional</label>
<input type="number" id="artAreaWeight" placeholder="z.B. 5300" step="1" min="0">
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="addArticle()">➕ Hinzufügen</button>
<button class="btn btn-secondary" onclick="clearArticleForm()">Zurücksetzen</button>
</div>
<h3 style="font-size:14px;margin-bottom:12px;margin-top:30px;">Artikel-Liste</h3>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Nr.</th>
<th>Name</th>
<th>Dicke (mm)</th>
<th>Area Weight</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="articleTableBody">
<tr><td colspan="5" style="text-align:center;color:#999;">Keine Artikel</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB: CONFIG -->
<div id="config" class="tab-content">
<div class="card">
<h2>Konfiguration (config.json)</h2>
<p>Gabelstapler-Regeln und Schwellenwerte - JSON direkt bearbeiten</p>
<div id="configAlert" class="alert"></div>
<textarea id="configJson" placeholder="config.json wird hier angezeigt..."></textarea>
<div class="button-group">
<button class="btn btn-primary" onclick="saveConfigJson()">💾 Aktualisieren</button>
<button class="btn btn-secondary" onclick="resetConfigJson()">🔄 Zurücksetzen</button>
</div>
<p style="font-size:11px;color:#999;margin-top:16px;">
💡 JSON wird automatisch validiert. Im Fehlerfall siehst du eine Fehlermeldung.
</p>
</div>
</div>
<!-- TAB: TOOLS -->
<div id="tools" class="tab-content">
<div class="card">
<h2>📥 Dateien verwalten</h2>
<p>Herunterladen, hochladen oder zurücksetzen</p>
<div id="toolsAlert" class="alert"></div>
<h3 style="font-size:13px;margin-bottom:12px;">Download</h3>
<div class="button-group">
<button class="btn btn-success" onclick="downloadArticleData()">📥 article-data.json</button>
<button class="btn btn-success" onclick="downloadConfig()">📥 config.json</button>
<button class="btn btn-success" onclick="downloadAll()">📦 Alle Dateien (ZIP)</button>
</div>
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">Upload</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Dateien hochladen und überschreiben:</p>
<div class="button-group">
<button class="btn btn-secondary" onclick="document.getElementById('uploadFile').click()">📤 JSON-Datei hochladen</button>
</div>
<input type="file" id="uploadFile" class="file-input" accept=".json" onchange="handleFileUpload(event)">
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">Daten</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Alle bearbeiteten Daten als ein JSON exportieren:</p>
<button class="btn btn-primary" onclick="exportAllAsJson()">💾 Alles exportieren</button>
<h3 style="font-size:13px;margin-bottom:12px;margin-top:20px;">⚠️ Vorsicht</h3>
<p style="font-size:12px;color:#666;margin-bottom:10px;">Bearbeitete Daten zurücksetzen (Achtung: nicht rückgängig!):</p>
<button class="btn btn-danger" onclick="if(confirm('Wirklich auf Original zurücksetzen?')) resetAll()">🔄 Auf Standardwerte zurücksetzen</button>
</div>
</div>
</div>
</div>
<script>
// ═════════════════════════════════════════════════════════════════════════════
// CONFIG
// ═════════════════════════════════════════════════════════════════════════════
const ADMIN_PASSWORD = 'admin123'; // ÄNDERN!
// Beispiel article-data.json (wird von Server geladen)
let articlesData = [];
// Beispiel config.json (wird von Server geladen)
let configData = {};
// ═════════════════════════════════════════════════════════════════════════════
// INIT
// ═════════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
loadDataFromServer();
document.getElementById('passwordInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') checkPassword();
});
});
function loadDataFromServer() {
// Versuche, Daten vom Server zu laden
Promise.all([
fetch('article-data.json').then(r => r.json()).catch(() => []),
fetch('config.json').then(r => r.json()).catch(() => ({}))
]).then(([articles, config]) => {
articlesData = articles || [];
configData = config || {};
console.log('✅ Daten geladen:', articlesData.length, 'Artikel, config vorhanden');
});
}
// ═════════════════════════════════════════════════════════════════════════════
// AUTH
// ═════════════════════════════════════════════════════════════════════════════
function checkPassword() {
const pwd = document.getElementById('passwordInput').value;
if (pwd === ADMIN_PASSWORD) {
document.getElementById('passwordOverlay').style.display = 'none';
document.getElementById('mainApp').style.display = 'block';
refreshUI();
} else {
alert('❌ Falsches Passwort!');
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
}
function logout() {
document.getElementById('mainApp').style.display = 'none';
document.getElementById('passwordOverlay').style.display = 'flex';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
// ═════════════════════════════════════════════════════════════════════════════
// UI
// ═════════════════════════════════════════════════════════════════════════════
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById(tabName).classList.add('active');
event.target.classList.add('active');
if (tabName === 'config') loadConfigJson();
if (tabName === 'articles') refreshArticleTable();
}
function showAlert(containerId, message, type, duration = 4000) {
const alert = document.getElementById(containerId);
alert.className = `alert show alert-${type}`;
alert.textContent = message;
if (duration) setTimeout(() => { alert.classList.remove('show'); }, duration);
}
function refreshUI() {
refreshArticleTable();
loadConfigJson();
updateStats();
}
function updateStats() {
document.getElementById('articleCount').textContent = articlesData.length;
}
// ═════════════════════════════════════════════════════════════════════════════
// ARTICLES
// ═════════════════════════════════════════════════════════════════════════════
function addArticle() {
const nr = document.getElementById('artNr').value.trim();
const name = document.getElementById('artName').value.trim();
const thickness = parseFloat(document.getElementById('artThickness').value) || 0;
const area_weight = parseFloat(document.getElementById('artAreaWeight').value) || 0;
if (!nr || !name || thickness === 0) {
showAlert('articleAlert', '❌ Artikel-Nr., Name und Dicke sind erforderlich!', 'error');
return;
}
if (articlesData.some(a => a.nr === nr)) {
showAlert('articleAlert', '❌ Artikel-Nr. existiert bereits!', 'error');
return;
}
articlesData.push({ nr, name, thickness, area_weight });
showAlert('articleAlert', '✅ Artikel hinzugefügt!', 'success');
clearArticleForm();
refreshArticleTable();
updateStats();
}
function deleteArticle(nr) {
if (confirm('Artikel ' + nr + ' wirklich löschen?')) {
articlesData = articlesData.filter(a => a.nr !== nr);
showAlert('articleAlert', '✅ Artikel gelöscht!', 'success');
refreshArticleTable();
updateStats();
}
}
function refreshArticleTable() {
const tbody = document.getElementById('articleTableBody');
if (articlesData.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#999;">Keine Artikel</td></tr>';
return;
}
tbody.innerHTML = articlesData.map(a => `
<tr>
<td><strong>${a.nr}</strong></td>
<td>${a.name}</td>
<td>${a.thickness.toFixed(3)}</td>
<td>${a.area_weight ? a.area_weight : '—'}</td>
<td>
<div class="action-buttons">
<button class="edit-btn" onclick="editArticle('${a.nr}')">✎ Bearbeiten</button>
<button class="delete-btn" onclick="deleteArticle('${a.nr}')">✕ Löschen</button>
</div>
</td>
</tr>
`).join('');
}
function editArticle(nr) {
const article = articlesData.find(a => a.nr === nr);
if (!article) return;
const newThickness = prompt('Produktdicke (mm):', article.thickness);
if (newThickness !== null) {
article.thickness = parseFloat(newThickness) || 0;
showAlert('articleAlert', '✅ Aktualisiert!', 'success');
refreshArticleTable();
}
}
function clearArticleForm() {
document.getElementById('artNr').value = '';
document.getElementById('artName').value = '';
document.getElementById('artThickness').value = '';
document.getElementById('artAreaWeight').value = '';
}
// ═════════════════════════════════════════════════════════════════════════════
// CONFIG
// ═════════════════════════════════════════════════════════════════════════════
function loadConfigJson() {
document.getElementById('configJson').value = JSON.stringify(configData, null, 2);
}
function saveConfigJson() {
try {
configData = JSON.parse(document.getElementById('configJson').value);
showAlert('configAlert', '✅ Konfiguration aktualisiert!', 'success');
} catch (e) {
showAlert('configAlert', '❌ Ungültiges JSON: ' + e.message, 'error', 0);
}
}
function resetConfigJson() {
loadConfigJson();
showAlert('configAlert', '🔄 Zurückgesetzt', 'info');
}
// ═════════════════════════════════════════════════════════════════════════════
// DOWNLOADS / UPLOADS
// ═════════════════════════════════════════════════════════════════════════════
function downloadFile(data, filename) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadArticleData() {
downloadFile(articlesData, 'article-data.json');
showAlert('toolsAlert', '✅ article-data.json heruntergeladen', 'success');
}
function downloadConfig() {
downloadFile(configData, 'config.json');
showAlert('toolsAlert', '✅ config.json heruntergeladen', 'success');
}
function downloadAll() {
const allData = { articles: articlesData, config: configData, exported_at: new Date().toISOString() };
downloadFile(allData, 'roll-calculator-backup-' + new Date().toISOString().slice(0,10) + '.json');
showAlert('toolsAlert', '✅ Alle Daten heruntergeladen', 'success');
}
function exportAllAsJson() {
downloadAll();
}
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
if (data.articles) articlesData = data.articles;
if (data.config) configData = data.config;
if (data.articleData) articlesData = data.articleData; // alternate key
showAlert('toolsAlert', '✅ Datei hochgeladen und geladen!', 'success');
refreshUI();
} catch (err) {
showAlert('toolsAlert', '❌ Fehler beim Laden der Datei: ' + err.message, 'error', 0);
}
};
reader.readAsText(file);
event.target.value = ''; // Reset file input
}
function resetAll() {
articlesData = [];
configData = {};
showAlert('toolsAlert', '🔄 Auf Standardwerte zurückgesetzt', 'success');
refreshUI();
}
</script>
</body>
</html>
+366 -37
View File
@@ -3,12 +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 flask_httpauth import HTTPBasicAuth
from functools import wraps
import os
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 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()
@@ -19,21 +52,71 @@ auth = HTTPBasicAuth()
# Benutzer für Beta-Phase (in Produktion aus env-Variablen laden!)
BETA_USERS = {
"beta": "rollcalc_beta_2026", # ⚠️ ÄNDERN!
"naue": "naue_access_2026", # ⚠️ ÄNDERN!
"cniehues": "beta_test_2026", # ⚠️ ÄNDERN!
"lvollmert": "Geheim!", # ⚠️ ÄNDERN!
"mtazl": "rollcalc", # ⚠️ ÄNDERN!
"controlling": "beta2026" # ⚠️ ÄNDERN!
}
# Optional: Admin-Features (z.B. Logs, Stats)
ADMIN_USERS = {
"admin": "admin_secure_pwd_2026" # ⚠️ ÄNDERN!
"beta": {
"password_hash": "pbkdf2:sha256:600000$HNtF3VdZKdmtg8Vw$8f976a99a457c5e924916dc6f735dd631042c8c126af8d4bda9abcd7532d904f"
},
"naue": {
"password_hash": "pbkdf2:sha256:600000$RVPiW2mYXJJIp3d7$0f838b8619d386e2da57e60ae8ccb944bb0f9f63d81ce3d174ec3034b0abcd19"
},
"cniehues": {
"password_hash": "pbkdf2:sha256:600000$iNmhakf34xk26xrz$ae78846461370c52b7f56fad5ac0ae8e33860d6f00075ac78e3a5b8c31d51a3d"
},
"lvollmert": {
"password_hash": "pbkdf2:sha256:600000$8FGop5ymmHpuIqfK$8ba223e20c514cf6bc1297435a2e7e2be81668951297f0e01ad6a931718ad7de"
},
"mtazl": {
"password_hash": "pbkdf2:sha256:600000$H19skoalhWxlLnY5$d30bfeae38470b19900648fd110978b3677607c3a161d663a7e5d5c2167a3710"
},
"controlling": {
"password_hash": "pbkdf2:sha256:600000$ksj86lrKz6nnSpXz$5161e0b5c76bd72d6b2cd04866ef49e69f463f474d7e47075171894bf3366f29"
}
}
# Logging für Auditing
LOG_FILE = "access_log.json"
BUILD_INFO_FILE = "build_info.json"
UNKNOWN_BUILD_INFO = {
"version": "unknown",
"branch": "unknown",
"commit": "unknown",
"timestamp": "unknown"
}
# ============================================================================
# BUILD INFO
# ============================================================================
def load_build_info():
"""Load deployment/build metadata for templates."""
try:
with open(BUILD_INFO_FILE, "r") as f:
data = json.load(f)
except Exception as e:
print(f"[Build Info Error] {e}")
return UNKNOWN_BUILD_INFO.copy()
if not isinstance(data, dict):
return UNKNOWN_BUILD_INFO.copy()
build_info = UNKNOWN_BUILD_INFO.copy()
for key in build_info:
value = data.get(key)
if isinstance(value, str) and value.strip():
build_info[key] = value.strip()
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
@@ -42,11 +125,16 @@ LOG_FILE = "access_log.json"
@auth.verify_password
def verify_password(username, password):
"""Verify HTTP Basic Auth credentials"""
all_users = {**BETA_USERS, **ADMIN_USERS}
if username in all_users and all_users[username] == password:
user_config = BETA_USERS.get(username)
if not user_config:
return None
password_hash = user_config.get("password_hash")
if password_hash and check_password_hash(password_hash, password):
return username
return None
def log_access(username, endpoint, method, status=200):
"""Log all access attempts for audit trail"""
log_entry = {
@@ -76,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
@@ -85,40 +178,273 @@ 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():
"""Get current authenticated user info"""
user = auth.current_user()
is_admin = user in ADMIN_USERS
return jsonify({
"username": user,
"authenticated": True,
"is_admin": is_admin,
"timestamp": datetime.now().isoformat()
}), 200
@app.route("/admin/logs", methods=["GET"])
@app.route("/api/calculations/roll", methods=["POST"])
@auth.login_required
def get_logs():
"""View access logs - admin only"""
user = auth.current_user()
if user not in ADMIN_USERS:
log_access(user, "/admin/logs", "GET", 403)
return jsonify({"error": "Unauthorized"}), 403
log_access(user, "/admin/logs", "GET")
logs = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, "r") as f:
logs = json.load(f)
return jsonify({"logs": logs}), 200
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
@@ -129,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
# ============================================================================
@@ -146,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 ║
@@ -157,10 +486,10 @@ if __name__ == "__main__":
║ Starten auf: http://localhost:5000 ║
╚═══════════════════════════════════════════════════════════╝
""")
app.run(
host="0.0.0.0",
port=5000,
debug=False,
use_reloader=True
use_reloader=False
)
+6
View File
@@ -0,0 +1,6 @@
{
"version": "v0.4",
"branch": "main",
"commit": "fe267b8",
"timestamp": "2026-07-27T11:08:47+02:00"
}
+153
View File
@@ -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
+812
View File
@@ -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
View File
@@ -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)
+16
View File
@@ -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 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
2 Bento 1 Bento1, B1 2501 876 300 150, 170, 200, 235 6 0,9 5 4,7 Espelkamp (Germany)
3 Bento 2 Bento2, B2 2501 1000 250 150, 170, 200, 235 6 0,9 5 4,7 Selangor (Malaysia)
4 K1 K 1 800 1100 200 130, 150, 170 20 1 6,1 4 Selangor (Malaysia)
5 K3/4 K 34, K 3/4 800 1100 200 130, 150, 170 15 1 6,1 4 Espelkamp (Germany)
6 K5 K 5 800 1200 200 130, 150, 170 20 1 6,1 4 Adorf (Germany)
7 K6 K 6 800 1100 200 130, 150, 170 20 1 6,1 4 Selangor (Malaysia)
8 K7 K 7 700 1100 300 130, 150, 170 40 0,6 6,1 4 Espelkamp (Germany)
9 E1 E 1 2500 1000 200 150, 170 10 0,6 7,5 7,5 Espelkamp (Germany)
10 E4 E 4 2000 900 200 150, 170 10 0,5 5,1 5,1 Espelkamp (Germany)
11 Gitter 1 G1, G 1 400 600 200 140 4,75 4 Adorf (Germany)
12 Gitter 2 G2, G 2 400 600 200 140 4,75 4 Adorf (Germany)
13 Gitter 3 G3, G 3 400 600 200 140 4,75 4 Adorf (Germany)
14 Gitter 4 G4, G 4 400 600 200 140 4,75 4 Adorf (Germany)
15 Secudrain 1/2 Sec 1/2, Secudrain 12, Sec 12 400 800 200 120 4 2 Adorf (Germany)
16 Secudrain 3 Sec 3 400 800 200 120 2,1 1,8 Adorf (Germany)
+2
View File
@@ -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
```
+166
View File
@@ -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.
+28
View File
@@ -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.
View File
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""
Automatischer Fix für article-data.json Fetch-URLs in Flask
Ersetzt relative URLs durch /static/ URLs
"""
import re
import os
from pathlib import Path
def fix_html_file(html_path):
"""Ersetzt die alten Fetch-URLs durch die neuen Flask-URLs"""
if not os.path.exists(html_path):
print(f"❌ Datei nicht gefunden: {html_path}")
return False
print(f"📖 Öffne: {html_path}")
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
original_content = content
changes = []
# Fix 1: fetch('article-data.json') → fetch('/static/article-data.json')
if "fetch('article-data.json')" in content or 'fetch("article-data.json")' in content:
content = content.replace("fetch('article-data.json')", "fetch('/static/article-data.json')")
content = content.replace('fetch("article-data.json")', 'fetch("/static/article-data.json")')
changes.append("✓ article-data.json URL aktualisiert")
# Fix 2: fetch('config.json') → fetch('/static/config.json')
if "fetch('config.json')" in content or 'fetch("config.json")' in content:
content = content.replace("fetch('config.json')", "fetch('/static/config.json')")
content = content.replace('fetch("config.json")', 'fetch("/static/config.json")')
changes.append("✓ config.json URL aktualisiert")
# Fix 3: register('service-worker.js') → register('/static/service-worker.js')
if "register('service-worker.js')" in content or 'register("service-worker.js")' in content:
content = content.replace("register('service-worker.js')", "register('/static/service-worker.js')")
content = content.replace('register("service-worker.js")', 'register("/static/service-worker.js")')
changes.append("✓ service-worker.js URL aktualisiert")
if content != original_content:
# Backup erstellen
backup_path = html_path + ".backup"
with open(backup_path, 'w', encoding='utf-8') as f:
f.write(original_content)
print(f"💾 Backup erstellt: {backup_path}")
# Neue Version speichern
with open(html_path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"✅ HTML aktualisiert: {html_path}")
for change in changes:
print(f" {change}")
return True
else:
print("⚠️ Keine Änderungen notwendig (URLs sind bereits korrekt)")
return False
def check_static_files():
"""Prüft ob die benötigten Dateien im static/ Ordner sind"""
print("\n🔍 Prüfe static/ Verzeichnis...")
files_to_check = ['article-data.json', 'config.json']
found = []
missing = []
for filename in files_to_check:
path = os.path.join('static', filename)
if os.path.exists(path):
size = os.path.getsize(path)
found.append(f" ✓ {filename} ({size} bytes)")
else:
missing.append(f" ❌ {filename} (NICHT GEFUNDEN)")
for msg in found:
print(msg)
for msg in missing:
print(msg)
return len(missing) == 0
if __name__ == "__main__":
print("=" * 60)
print("Fix für article-data.json Flask-URLs")
print("=" * 60)
html_file = "templates/roll_calculator.html"
success = fix_html_file(html_file)
files_ok = check_static_files()
print("\n" + "=" * 60)
if success or files_ok:
print("✅ Fix abgeschlossen!")
print("\n📝 Nächste Schritte:")
print(" 1. Browser neuladen (Ctrl+Shift+R)")
print(" 2. Öffne Browser Console (F12)")
print(" 3. Du solltest sehen:")
print(" 'Article data loaded: XXX items'")
print(" 'Config updated from file'")
else:
print("⚠️ Keine Änderungen nötig")
print("=" * 60)
+164
View File
@@ -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.
+346
View File
@@ -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}")
+718
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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())
+2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -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())
+246
View File
@@ -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,
})
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Create password hashes or user config snippets for RollCalc Basic Auth users.
"""
import argparse
import getpass
import json
from werkzeug.security import generate_password_hash
def build_parser():
parser = argparse.ArgumentParser(
description="Generate RollCalc password hashes or user config entries."
)
parser.add_argument(
"username",
nargs="?",
help="Optional username for a generated user config snippet.",
)
parser.add_argument(
"--password",
help="Password to hash. Omit to enter it securely via prompt.",
)
parser.add_argument(
"--json",
action="store_true",
help="Output a JSON object for the user entry.",
)
return parser
def main():
args = build_parser().parse_args()
password = args.password
if password is None:
password = getpass.getpass("Password: ")
password_hash = generate_password_hash(password)
if args.username:
entry = {args.username: {"password_hash": password_hash}}
if args.json:
print(json.dumps(entry, indent=2))
else:
print(f'"{args.username}": {{')
print(f' "password_hash": "{password_hash}"')
print("}")
else:
print(password_hash)
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
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": "$VERSION",
"branch": "$(git branch --show-current)",
"commit": "$(git rev-parse --short HEAD)",
"timestamp": "$(date -Iseconds)"
}
EOF
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=RollCalc Flask Application
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/html
ExecStart=/usr/bin/python3 /var/www/html/app.py
Restart=on-failure
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
-568
View File
@@ -1,568 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0066cc">
<title>Admin Panel – Roll Calculator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f4f6f9;
color: #333;
min-height: 100vh;
}
/* ---- HEADER ---- */
.admin-header {
background: linear-gradient(135deg, #003366 0%, #0066cc 100%);
color: white;
padding: 20px 32px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.admin-header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.5px;
}
.admin-header .subtitle {
font-size: 12px;
opacity: 0.85;
}
.admin-header .user-info {
font-size: 12px;
opacity: 0.9;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.admin-header .user-info strong {
opacity: 1;
font-weight: 600;
}
/* ---- CONTAINER ---- */
.admin-container {
max-width: 1300px;
margin: 30px auto;
padding: 0 20px 60px;
}
/* ---- STATS GRID ---- */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
border-left: 4px solid #0066cc;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.stat-card.warning {
border-left-color: #f0b429;
}
.stat-card.danger {
border-left-color: #dc3545;
}
.stat-card .label {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 8px;
font-weight: 600;
}
.stat-card .value {
font-size: 36px;
font-weight: 700;
color: #0066cc;
line-height: 1;
}
.stat-card.warning .value {
color: #f0b429;
}
.stat-card.danger .value {
color: #dc3545;
}
.stat-card .sublabel {
font-size: 11px;
color: #999;
margin-top: 8px;
}
/* ---- LOGS SECTION ---- */
.logs-section {
background: white;
border-radius: 10px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.logs-header h2 {
font-size: 18px;
color: #003366;
font-weight: 700;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
background: #0066cc;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: background 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: #0052a3;
}
.btn.secondary {
background: #e0e0e0;
color: #333;
}
.btn.secondary:hover {
background: #d0d0d0;
}
.btn-refresh {
animation: spin 1s linear infinite;
}
.btn-refresh.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ---- TABLE ---- */
.logs-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.logs-table thead {
background: #f0f4fa;
border-bottom: 2px solid #0066cc;
}
.logs-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
}
.logs-table td {
padding: 10px 12px;
border-bottom: 1px solid #e8ecf0;
}
.logs-table tbody tr {
transition: background 0.15s;
}
.logs-table tbody tr:hover {
background: #f8f9fa;
}
.status-200 {
color: #28a745;
font-weight: 600;
background: #f0f9f6;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
.status-401 {
color: #dc3545;
font-weight: 600;
background: #fff0f0;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
.status-403 {
color: #f0b429;
font-weight: 600;
background: #fffbea;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
/* ---- LOADING / EMPTY ---- */
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.empty {
text-align: center;
padding: 40px;
color: #999;
font-style: italic;
}
.error {
background: #fff0f0;
color: #dc3545;
padding: 12px;
border-radius: 5px;
border-left: 4px solid #dc3545;
margin: 12px 0;
}
/* ---- FILTERS ---- */
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 13px;
}
.filter-input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
/* ---- FOOTER ---- */
.admin-footer {
text-align: center;
font-size: 11px;
color: #aaa;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e8ecf0;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.admin-header .user-info {
align-items: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
.logs-header {
flex-direction: column;
align-items: flex-start;
}
.controls {
width: 100%;
}
.btn {
flex: 1;
justify-content: center;
}
.logs-table {
font-size: 12px;
}
.logs-table th, .logs-table td {
padding: 8px;
}
}
</style>
</head>
<body>
<!-- HEADER -->
<div class="admin-header">
<div>
<h1>🔐 Admin Panel</h1>
<div class="subtitle">Roll Calculator – Naue GmbH & Co. KG</div>
</div>
<div class="user-info">
<span>Angemeldet als:</span>
<strong id="authUser">admin</strong>
</div>
</div>
<!-- MAIN CONTAINER -->
<div class="admin-container">
<!-- STATISTICS -->
<div class="stats-grid" id="statsGrid">
<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #999;">
⏳ Statistiken werden geladen...
</div>
</div>
<!-- LOGS SECTION -->
<div class="logs-section">
<!-- HEADER -->
<div class="logs-header">
<h2>📋 Access Logs</h2>
<div class="controls">
<button class="btn" id="refreshBtn" onclick="refreshData()">
<span id="refreshIcon">🔄</span> Aktualisieren
</button>
<button class="btn secondary" onclick="downloadLogs()">
⬇️ Download
</button>
</div>
</div>
<!-- FILTERS -->
<div class="filters">
<input
type="text"
class="filter-input"
placeholder="Nach Benutzer filtern..."
id="filterUser"
onkeyup="filterLogs()"
>
<input
type="text"
class="filter-input"
placeholder="Nach Endpoint filtern..."
id="filterEndpoint"
onkeyup="filterLogs()"
>
</div>
<!-- TABLE -->
<div id="errorContainer"></div>
<table class="logs-table">
<thead>
<tr>
<th>Zeitstempel</th>
<th>Benutzer</th>
<th>Endpoint</th>
<th>Methode</th>
<th style="width: 80px;">Status</th>
</tr>
</thead>
<tbody id="logsList">
<tr>
<td colspan="5" class="loading">Logs werden geladen...</td>
</tr>
</tbody>
</table>
</div>
<!-- FOOTER -->
<div class="admin-footer">
ℹ️ Logs werden automatisch alle 30 Sekunden aktualisiert |
Letztes Update: <span id="lastUpdate">-</span>
</div>
</div>
<!-- SCRIPTS -->
<script>
let allLogs = [];
// Get authenticated user from API
async function getAuthUser() {
try {
const res = await fetch('/api/user');
const data = await res.json();
document.getElementById('authUser').textContent = data.username || 'admin';
} catch (e) {
console.warn('Konnte Benutzerinformation nicht laden');
}
}
// Refresh Data (Statistiken + Logs)
async function refreshData() {
const btn = document.getElementById('refreshBtn');
const icon = document.getElementById('refreshIcon');
try {
icon.style.animation = 'spin 1s linear infinite';
// Load Statistics
const statsRes = await fetch('/api/admin/stats');
if (!statsRes.ok) throw new Error('Statistiken konnten nicht geladen werden');
const stats = await statsRes.json();
// Load Logs
const logsRes = await fetch('/api/admin/logs');
if (!logsRes.ok) throw new Error('Logs konnten nicht geladen werden');
const logsData = await logsRes.json();
allLogs = logsData.logs || [];
// Render Statistics
renderStats(stats);
// Render Logs
renderLogs(allLogs);
// Update timestamp
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE');
// Clear error
document.getElementById('errorContainer').innerHTML = '';
} catch (error) {
console.error('Fehler beim Laden der Daten:', error);
document.getElementById('errorContainer').innerHTML =
'<div class="error">❌ ' + error.message + '</div>';
} finally {
icon.style.animation = 'none';
}
}
// Render Statistics
function renderStats(stats) {
const html = `
<div class="stat-card">
<div class="label">📊 Gesamte Anfragen</div>
<div class="value">${stats.total_requests || 0}</div>
</div>
<div class="stat-card">
<div class="label">👥 Eindeutige Benutzer</div>
<div class="value">${stats.unique_users || 0}</div>
</div>
<div class="stat-card danger">
<div class="label">⛔ Fehlgeschlagene Logins</div>
<div class="value">${stats.failed_logins || 0}</div>
</div>
`;
document.getElementById('statsGrid').innerHTML = html;
}
// Render Logs
function renderLogs(logs) {
if (!logs || logs.length === 0) {
document.getElementById('logsList').innerHTML =
'<tr><td colspan="5" class="empty">Keine Logs verfügbar</td></tr>';
return;
}
const html = logs.reverse().map(log => {
const time = new Date(log.timestamp).toLocaleString('de-DE');
const statusClass = `status-${log.status}`;
return `
<tr>
<td>${time}</td>
<td><strong>${log.username}</strong></td>
<td><code>${log.endpoint}</code></td>
<td>${log.method}</td>
<td><span class="${statusClass}">${log.status}</span></td>
</tr>
`;
}).join('');
document.getElementById('logsList').innerHTML = html;
}
// Filter Logs
function filterLogs() {
const userFilter = document.getElementById('filterUser').value.toLowerCase();
const endpointFilter = document.getElementById('filterEndpoint').value.toLowerCase();
const filtered = allLogs.filter(log =>
log.username.toLowerCase().includes(userFilter) &&
log.endpoint.toLowerCase().includes(endpointFilter)
);
renderLogs(filtered);
}
// Download Logs as JSON
function downloadLogs() {
const data = JSON.stringify(allLogs, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `access-logs-${new Date().toISOString()}.json`;
a.click();
window.URL.revokeObjectURL(url);
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
getAuthUser();
refreshData();
// Auto-refresh every 30 seconds
setInterval(refreshData, 30000);
});
</script>
</body>
</html>
+540 -155
View File
@@ -43,11 +43,17 @@
.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; }
.result-value { font-size: 28px; font-weight: 700; color: #1F5438; line-height: 1.2; }
.result-range { font-size: 12px; color: #1F5438; margin-top: 4px; font-weight: 500; }
.result-range div { margin-top: 2px; }
.result-range .range-warning { color: #9a6700; font-weight: 700; }
.result-range .range-ok { color: #1F5438; font-weight: 700; }
.forklift-check { border-radius: 7px; padding: 14px 16px; margin-top: 14px; font-size: 13px; line-height: 1.6; display: none; }
.forklift-check.show { display: block; }
.forklift-check.fc-ok { background: #eafaf1; border: 1.5px solid #28a745; }
@@ -60,6 +66,86 @@
.fc-req-nok { color: #dc3545; font-weight: 600; }
.formula-box { background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #666; margin-top: 18px; font-family: 'Courier New', monospace; }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; margin-top: 30px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
.disclaimer-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(20, 28, 34, 0.72);
}
.disclaimer-overlay.is-hidden { display: none; }
.disclaimer-dialog {
width: min(760px, 100%);
max-height: min(86vh, 820px);
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.28);
overflow: hidden;
}
.disclaimer-header {
padding: 18px 22px;
background: #1F5438;
color: #fff;
}
.disclaimer-header h2 { font-size: 18px; margin: 0; }
.disclaimer-content {
padding: 20px 22px;
overflow-y: auto;
line-height: 1.5;
font-size: 14px;
}
.disclaimer-content h3 {
margin: 0 0 8px;
color: #1F5438;
font-size: 15px;
}
.disclaimer-content p { margin: 0 0 12px; }
.disclaimer-language + .disclaimer-language {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid #e0e8f0;
}
.disclaimer-actions {
padding: 16px 22px;
border-top: 1px solid #e0e8f0;
background: #f8fafa;
}
.disclaimer-confirm {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
font-size: 13px;
color: #333;
}
.disclaimer-confirm input {
width: 18px;
height: 18px;
margin-top: 1px;
flex: 0 0 auto;
}
.disclaimer-btn {
width: 100%;
border: none;
border-radius: 6px;
padding: 11px 18px;
background: #2BAC70;
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.disclaimer-btn:hover { background: #1F5438; }
.disclaimer-btn:disabled {
background: #b8c7bf;
cursor: not-allowed;
}
/* ============================================
LOAD OPTIMIZER STYLES
@@ -336,6 +422,37 @@
</head>
<body>
<div class="disclaimer-overlay" id="disclaimerOverlay" role="dialog" aria-modal="true" aria-labelledby="disclaimerTitle">
<div class="disclaimer-dialog">
<div class="disclaimer-header">
<h2 id="disclaimerTitle">Disclaimer</h2>
</div>
<div class="disclaimer-content">
<section class="disclaimer-language" aria-label="Deutscher Disclaimer">
<h3>Deutsch</h3>
<p><strong>⚠️ Disclaimer</strong></p>
<p>Dieses Tool ist ausschließlich zur persönlichen, internen Verwendung bestimmt. Die Weitergabe von Logins ist nicht gestattet.</p>
<p>Berechtigt sind nur: Produktmanagement, Produktionsleitung, R&amp;D, Controlling und Logistik.</p>
<p>Wichtig: Alle Eingaben und Ausgaben müssen vor der Verwendung auf Plausibilität geprüft werden. Die Datenbasis für Materialdicke und Flächengewicht wird automatisch erstellt und ist weder geprüft noch erhebt sie Anspruch auf Vollständigkeit.</p>
</section>
<section class="disclaimer-language" aria-label="English disclaimer">
<h3>English</h3>
<p><strong>⚠️ Disclaimer</strong></p>
<p>This tool is exclusively for personal, internal use. Sharing login credentials is prohibited.</p>
<p>Authorized departments only: Product Management, Production Management, R&amp;D, Controlling, and Logistics.</p>
<p>Important: All inputs and outputs must be verified for plausibility before use. The material thickness and area weight data is automatically generated and has not been reviewed. It does not claim to be complete or comprehensive.</p>
</section>
</div>
<div class="disclaimer-actions">
<label class="disclaimer-confirm" for="disclaimerAccepted">
<input type="checkbox" id="disclaimerAccepted">
<span>I have read and understood the disclaimer / Ich habe den Disclaimer gelesen und verstanden.</span>
</label>
<button class="disclaimer-btn" id="disclaimerConfirmBtn" type="button" disabled>Confirm / Bestätigen</button>
</div>
</div>
</div>
<div class="header">
<h1>Naue Roll Diameter Calculator</h1>
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
@@ -355,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>
@@ -395,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">
@@ -461,6 +577,27 @@
<span class="unit-label">kg</span>
</div>
</div>
<div class="form-group">
<label>Max. Roll Diameter</label>
<div class="input-with-unit">
<input type="number" id="d-target-di" placeholder="mm" min="0" step="1">
<span class="unit-label">mm</span>
</div>
</div>
<div class="form-group">
<label>Max. Roll Weight</label>
<div class="input-with-unit">
<input type="number" id="d-max-weight" placeholder="kg" min="0" step="1">
<span class="unit-label">kg</span>
</div>
</div>
<div class="form-group">
<label>Minimum Product Length</label>
<div class="input-with-unit">
<input type="number" id="d-min-le" placeholder="m" min="0" step="0.1">
<span class="unit-label">m</span>
</div>
</div>
</div>
</div>
@@ -478,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">
@@ -631,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>
@@ -734,6 +870,9 @@
<div class="footer">
Naue GmbH & Co. KG · Roll Diameter Calculator · Internal Application
<div class="footer-build-info">
Version {{ build_info.version }} · Branch {{ build_info.branch }} · Commit {{ build_info.commit }} · Built {{ build_info.timestamp }}
</div>
</div>
<script>
@@ -742,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
@@ -798,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();
}
}
});
@@ -844,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;
@@ -857,59 +1104,165 @@ 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') {
if (!d || !t) { alert('Please enter d and t.'); return; }
const D_target = D || 800;
const L_calc = (Math.PI / (4 * t)) * (D_target*D_target - d*d) / 1000;
resLabel.textContent = 'Length for Target Diameter ' + D_target + ' mm';
resVal.textContent = L_calc.toFixed(2) + ' m';
resRange.textContent = '';
document.getElementById('d-le').value = L_calc.toFixed(2);
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
const maxWeight = parseFloat(document.getElementById('d-max-weight').value) || 0;
const minLength = parseFloat(document.getElementById('d-min-le').value) || 0;
const D_target = parseFloat(document.getElementById('d-target-di').value) || 0;
if (!d || !t || !D_target || !wi || !aw || !maxWeight) {
alert('Please enter d, t, max. roll diameter, roll width, area weight, and max. roll weight.');
return;
}
if (D_target <= d) {
alert('Max. roll diameter must be larger than core diameter.');
return;
}
const L_diameter = (Math.PI / (4 * t)) * (D_target*D_target - d*d) / 1000;
const L_weight = maxWeight * 1000 / (aw * wi);
const L_allowed = Math.min(L_diameter, L_weight);
const D_result = Math.sqrt(d*d + (4*L_allowed*1000*t)/Math.PI);
const W_result = aw * L_allowed * wi / 1000;
const diameterUtil = D_result / D_target * 100;
const weightUtil = W_result / maxWeight * 100;
const lengthDiff = minLength > 0 ? L_allowed - minLength : null;
const epsilon = 0.0001;
let limitingFactor = 'Diameter + Weight';
if (L_diameter < L_weight - epsilon) {
limitingFactor = 'Diameter';
} else if (L_weight < L_diameter - epsilon) {
limitingFactor = 'Weight';
}
resLabel.textContent = 'Maximum Allowed Product Length';
resVal.textContent = L_allowed.toFixed(2) + ' m';
const minLengthText = minLength > 0 ? minLength.toFixed(2) + ' m' : 'not set';
const diffText = lengthDiff !== null ? lengthDiff.toFixed(2) + ' m' : 'n/a';
let minStatus = '<div>Minimum length not set</div>';
if (minLength > 0 && L_allowed < minLength) {
minStatus = '<div class="range-warning">Warning: maximum allowed length is below the minimum product length.</div>';
} else if (minLength > 0) {
minStatus = '<div class="range-ok">Minimum length OK</div>';
}
resRange.innerHTML =
'<div>Resulting Roll Diameter: ' + D_result.toFixed(1) + ' mm</div>' +
'<div>Resulting weight: ' + W_result.toFixed(1) + ' kg</div>' +
'<div>Minimum length: ' + minLengthText + '</div>' +
'<div>Difference: ' + diffText + '</div>' +
'<div>Limiting factor: ' + limitingFactor + '</div>' +
'<div>Utilization: Diameter ' + diameterUtil.toFixed(1) + '% | Weight ' + weightUtil.toFixed(1) + '%</div>' +
minStatus;
document.getElementById('d-le').value = L_allowed.toFixed(2);
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).toFixed(1);
document.getElementById('d-wo').value = weight;
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';
});
document.getElementById('d-reset').addEventListener('click', () => {
['d-co', 'd-th', 'd-tol', 'd-le', 'd-di', 'd-wi', 'd-aw', 'd-wo'].forEach(id => {
['d-co', 'd-th', 'd-tol', 'd-le', 'd-di', 'd-wi', 'd-aw', 'd-wo', 'd-target-di', 'd-max-weight', 'd-min-le'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = id === 'd-co' ? '150' : '';
});
document.getElementById('d-art').value = '';
document.getElementById('d-result').style.display = 'none';
document.getElementById('forkliftCheck').classList.remove('show');
invalidatePdfReport();
});
// =====================================================
@@ -955,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();
}
@@ -1109,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();
@@ -1131,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);
@@ -1146,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() {
@@ -1159,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);
@@ -1190,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;
@@ -1260,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>';
@@ -1301,6 +1669,23 @@ class LoadOptimizerUI {
document.addEventListener('DOMContentLoaded', () => {
window.loadOptimizerUI = new LoadOptimizerUI();
});
document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('disclaimerOverlay');
const checkbox = document.getElementById('disclaimerAccepted');
const confirmBtn = document.getElementById('disclaimerConfirmBtn');
if (!overlay || !checkbox || !confirmBtn) return;
checkbox.addEventListener('change', () => {
confirmBtn.disabled = !checkbox.checked;
});
confirmBtn.addEventListener('click', () => {
if (!checkbox.checked) return;
overlay.classList.add('is-hidden');
});
});
</script>
</body>
+215
View File
@@ -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()
+112
View File
@@ -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()
+852
View File
@@ -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()
+67
View File
@@ -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()
+329
View File
@@ -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"])
+558
View File
@@ -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()
+316
View File
@@ -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()
+27
View File
@@ -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()
+289
View File
@@ -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()
+231
View File
@@ -0,0 +1,231 @@
import base64
from datetime import datetime, timezone
import os
import tempfile
import unittest
from pdf_report import (
ReportValidationError,
report_from_calculation_result,
render_roll_report,
safe_report_filename,
validate_report_payload,
)
from roll_calculation import calculate_roll
try:
import app as rollcalc_app
except ModuleNotFoundError as error:
if error.name not in {"flask", "flask_httpauth", "werkzeug"}:
raise
rollcalc_app = None
def normal_payload(*, include_weight=True, include_warning=True):
payload = {
"report_type": "roll_diameter",
"article": {"number": "146900", "name": "Stex R 1801, 5.80 x 50 m"},
"inputs": [
{"label": "Core diameter", "value": "150.0 mm"},
{"label": "Core type", "value": "150mm PVC"},
{"label": "Product thickness", "value": "10.885 mm"},
{"label": "Thickness stddev", "value": "0.427 mm"},
{"label": "Product length", "value": "50.00 m"},
{"label": "Roll width", "value": "5.80 m"},
{"label": "Area weight", "value": "1767.2 g/m²"},
],
"diameters": {
"minimum": {"value": 612.3, "display": "612.3 mm"},
"average": {"value": 635.7, "display": "635.7 mm"},
"maximum": {"value": 658.1, "display": "658.1 mm"},
},
"roll_length": {"value": 50.0, "display": "50.00 m"},
"roll_weight": (
{"value": 512.5, "display": "512.5 kg"} if include_weight else None
),
"warnings": [],
"notes": [],
}
if include_warning:
payload["warnings"].append(
{
"severity": "warning",
"title": "Heavy Roll - Special Equipment Required",
"message": "Special equipment required. Core diameter too small.",
}
)
return payload
def normal_request(*, include_weight=True):
return {
"intent": "calculate_roll_diameter",
"article_number": "146900",
"roll_length_m": 50.0,
"width_m": 5.8 if include_weight else None,
"core_diameter_mm": 150.0,
"category": "bentofix",
"include_roll_weight": include_weight,
}
@unittest.skipUnless(rollcalc_app, "Flask dependencies are not installed")
class PdfEndpointTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.original_log_file = rollcalc_app.LOG_FILE
rollcalc_app.LOG_FILE = os.path.join(self.temp_dir.name, "access.json")
rollcalc_app.app.config.update(TESTING=True)
self.client = rollcalc_app.app.test_client()
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
self.auth_headers = {"Authorization": f"Basic {credentials}"}
def tearDown(self):
rollcalc_app.LOG_FILE = self.original_log_file
self.temp_dir.cleanup()
def post_report(self, payload):
return self.client.post(
"/api/reports/roll-calculation.pdf",
json=payload,
headers=self.auth_headers,
)
def test_normal_request_is_recalculated_into_one_page_pdf(self):
response = self.post_report(normal_request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.mimetype, "application/pdf")
self.assertTrue(response.data.startswith(b"%PDF-1.4"))
self.assertEqual(response.data.count(b"/Type /Page "), 1)
self.assertIn(b"CALCULATION RESULTS", response.data)
self.assertNotIn(b"CALCULATED DIAMETERS", response.data)
for expected in (
b"813.1 mm",
b"845.9 mm",
b"877.4 mm",
b"50.00 m",
b"512.5 kg",
b"within standard limits",
):
self.assertIn(expected, response.data)
self.assertGreaterEqual(response.data.count(b"INTERNAL USE ONLY"), 3)
self.assertIn("roll-calculation_146900_", response.headers["Content-Disposition"])
def test_report_without_optional_weight_still_succeeds(self):
response = self.post_report(
normal_request(include_weight=False)
)
self.assertEqual(response.status_code, 200)
self.assertNotIn(b"CALCULATED ROLL WEIGHT", response.data)
self.assertIn(b"845.9 mm", response.data)
def test_incomplete_or_caller_supplied_results_are_rejected(self):
incomplete = normal_request()
del incomplete["roll_length_m"]
response = self.post_report(incomplete)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.mimetype, "application/json")
caller_result = normal_request()
caller_result["diameters"] = {"average": 1}
response = self.post_report(caller_result)
self.assertEqual(response.status_code, 400)
def test_endpoint_requires_authentication(self):
response = self.client.post(
"/api/reports/roll-calculation.pdf", json=normal_payload()
)
self.assertEqual(response.status_code, 401)
class PdfReportModelTests(unittest.TestCase):
def test_low_level_renderer_consumes_validated_layout_model(self):
report = validate_report_payload(normal_payload())
pdf = render_roll_report(
report,
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
build_info={"version": "v0.4", "commit": "fe267b8"},
)
self.assertIn(b"612.3 mm", pdf)
self.assertIn(b"635.7 mm", pdf)
self.assertIn(b"658.1 mm", pdf)
self.assertIn(b"50.00 m", pdf)
def test_non_numeric_diameter_is_rejected(self):
payload = normal_payload()
payload["diameters"]["average"]["value"] = "635.7"
with self.assertRaises(ReportValidationError):
validate_report_payload(payload)
def test_display_value_mismatch_is_rejected(self):
payload = normal_payload()
payload["diameters"]["average"]["display"] = "999.9 mm"
with self.assertRaises(ReportValidationError):
validate_report_payload(payload)
def test_effective_roll_length_is_required_and_must_match_display(self):
missing = normal_payload()
del missing["roll_length"]
with self.assertRaises(ReportValidationError):
validate_report_payload(missing)
mismatched = normal_payload()
mismatched["roll_length"]["display"] = "60.00 m"
with self.assertRaises(ReportValidationError):
validate_report_payload(mismatched)
def test_filename_is_filesystem_safe(self):
filename = safe_report_filename(
"Article 12/../../unsafe",
datetime(2026, 8, 28, tzinfo=timezone.utc),
)
self.assertEqual(
filename,
"roll-calculation_Article-12-..-..-unsafe_2026-08-28_000000.pdf",
)
self.assertNotIn(":", filename)
def test_pdf_adapter_consumes_authoritative_calculation_result(self):
result = calculate_roll(normal_request())
report = report_from_calculation_result(result)
pdf = render_roll_report(
report,
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
build_info={"version": "v0.4", "commit": "fe267b8"},
)
self.assertIn(b"845.9 mm", pdf)
self.assertIn(b"50.00 m", pdf)
self.assertIn(b"512.5 kg", pdf)
def test_multiple_warnings_are_wrapped_without_creating_a_second_page(self):
payload = normal_payload()
payload["warnings"] = [
{
"severity": "warning",
"title": f"Warning {index}",
"message": (
"This complete engineering warning remains in the report and "
"wraps when the available line width is reached."
),
}
for index in range(1, 7)
]
report = validate_report_payload(payload)
pdf = render_roll_report(
report,
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
build_info={"version": "v0.4", "commit": "fe267b8"},
)
self.assertEqual(pdf.count(b"/Type /Page "), 1)
for index in range(1, 7):
self.assertIn(f"Warning {index}".encode("ascii"), pdf)
if __name__ == "__main__":
unittest.main()
+817
View File
@@ -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()
+55
View File
@@ -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()
+152
View File
@@ -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()
+241
View File
@@ -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}