feat: add conversational RollCalc assistant

This commit is contained in:
2026-08-29 22:37:56 +02:00
parent 3b44048250
commit fa3000b325
25 changed files with 6820 additions and 48 deletions
+78 -5
View File
@@ -4,7 +4,7 @@ Project-specific technical and domain knowledge for RollCalcPython.
## Purpose ## Purpose
RollCalcPython is an internal roll calculator for Naue roll products. The backend is a minimal Flask application that provides authentication, routing, static file delivery, and access logs. Most domain behavior runs in the browser inside `templates/roll_calculator.html`. RollCalcPython is an internal roll calculator for Naue roll products. The backend provides authentication, routing, static file delivery, access logs, and the authoritative direct roll-diameter calculation service. Other modes and the load optimizer still contain client-side behavior in `templates/roll_calculator.html`.
The tool is not intended as an unchecked source of truth. Inputs and outputs must be checked for plausibility before operational use. The tool is not intended as an unchecked source of truth. Inputs and outputs must be checked for plausibility before operational use.
@@ -19,6 +19,12 @@ Backend:
- `/static/<path:filename>` is intended to serve static files behind Basic Auth. - `/static/<path:filename>` is intended to serve static files behind Basic Auth.
- `/api/health` returns health/version information. - `/api/health` returns health/version information.
- `/api/user` returns the authenticated user. - `/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/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`. - Access events are appended to `access_log.json`.
- `build_info.json` is loaded at startup and exposed to all templates as `build_info`. - `build_info.json` is loaded at startup and exposed to all templates as `build_info`.
@@ -30,6 +36,47 @@ Frontend:
- `window.APP_CONFIG` is currently defined inline for forklift rules. - `window.APP_CONFIG` is currently defined inline for forklift rules.
- A disclaimer modal blocks use until the user checks the confirmation checkbox. - A disclaimer modal blocks use until the user checks the confirmation checkbox.
- The footer displays build metadata: version, branch, commit, and timestamp. - 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`, `calculate_roll()`, and `modify_calculation()`.
- 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 Info
@@ -79,7 +126,7 @@ Assumptions:
- No air gaps. - No air gaps.
- Roll geometry is derived mathematically, not physically measured in the app. - Roll geometry is derived mathematically, not physically measured in the app.
Nominal diameter formula used by the Direct Calculation tab: Nominal diameter formula used by `roll_calculation.calculate_roll()` and returned to the Direct Calculation tab:
```text ```text
D = sqrt(d^2 + (4 * L * 1000 * t) / pi) D = sqrt(d^2 + (4 * L * 1000 * t) / pi)
@@ -117,7 +164,7 @@ Variables:
## Roll Weight ## Roll Weight
Roll weight is calculated client-side when width, area weight, and length are available: For the authoritative direct roll-diameter flow, roll weight is calculated server-side when requested and width, area weight, and length are available:
```text ```text
weight_kg = area_weight_g_m2 * length_m * width_m / 1000 weight_kg = area_weight_g_m2 * length_m * width_m / 1000
@@ -125,6 +172,30 @@ weight_kg = area_weight_g_m2 * length_m * width_m / 1000
Material thickness and area weight values are loaded from article data and are generated automatically. They are not guaranteed to be complete or reviewed. 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 ## Article Data
Active data source: Active data source:
@@ -156,7 +227,7 @@ Known domain caveat from existing project notes:
## Forklift and Heavy-Roll Rules ## Forklift and Heavy-Roll Rules
The active template currently evaluates forklift warnings through `checkForklift()` and inline `window.APP_CONFIG`. The direct roll-diameter service evaluates the following active behavior. Other UI modes still evaluate the equivalent inline `window.APP_CONFIG` rules through `checkForklift()`.
Current inline rule behavior: Current inline rule behavior:
@@ -235,5 +306,7 @@ Login/access logging remains part of RollCalc, but log viewing and article-data
- Flask's default static route may conflict with the intended authenticated `/static/<path:filename>` behavior; verify effective routing before relying on protected static files. - 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. - Most active logic is concentrated in one large HTML template.
- Multiple similar static JavaScript files exist; not all are necessarily active. - Multiple similar static JavaScript files exist; not all are necessarily active.
- No automated test suite is visible. - The deterministic service, state mutation, template integration, PDF adapter/renderer, and Flask endpoints have standard-library `unittest` coverage under `tests/`.
- Some docs and filenames in `docs/` may reflect earlier package/archive versions. - 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.
+192 -6
View File
@@ -1,6 +1,6 @@
# RollCalcPython # RollCalcPython
Flask-based internal roll diameter calculator for Naue roll products. The app is a small authenticated Flask shell around a mostly client-side calculator UI. Flask-based internal roll diameter calculator for Naue roll products. Direct roll-diameter calculations are provided by a deterministic Python domain service and consumed by the authenticated browser UI and PDF path.
This README is intended for developers maintaining the project, not for end users. This README is intended for developers maintaining the project, not for end users.
@@ -11,6 +11,8 @@ This README is intended for developers maintaining the project, not for end user
- Flask-HTTPAuth 4.8.0 - Flask-HTTPAuth 4.8.0
- Vanilla HTML, CSS, and JavaScript - Vanilla HTML, CSS, and JavaScript
- JSON files for product data and forklift/load rules - 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`. Pinned Python dependencies are defined in `requirements.txt`.
@@ -49,6 +51,16 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
```text ```text
. .
├── app.py ├── app.py
├── conversation_service.py
├── core_presets.py
├── ollama_nlu.py
├── pdf_report.py
├── roll_calculation.py
├── rollcalc_chat.py
├── integrations/
│ └── openwebui/
│ ├── README.md
│ └── rollcalc_pipe.py
├── requirements.txt ├── requirements.txt
├── README.md ├── README.md
├── build_info.json ├── build_info.json
@@ -59,6 +71,8 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
├── service-worker.js ├── service-worker.js
├── templates/ ├── templates/
│ └── roll_calculator.html │ └── roll_calculator.html
├── tests/
│ └── test_pdf_report.py
├── static/ ├── static/
│ ├── article-data.json │ ├── article-data.json
│ ├── config.json │ ├── config.json
@@ -89,6 +103,12 @@ Implemented routes:
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. | | `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
| `/api/health` | `GET` | Basic Auth | Returns app health and version. | | `/api/health` | `GET` | Basic Auth | Returns app health and version. |
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. | | `/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/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. 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.
@@ -109,6 +129,147 @@ Do not commit real passwords or print them in logs.
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back. 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 ## Frontend Entry Point
The active UI is `templates/roll_calculator.html`. The active UI is `templates/roll_calculator.html`.
@@ -121,8 +282,10 @@ The template contains:
- Global state: - Global state:
- `window.ARTICLE_DATA` - `window.ARTICLE_DATA`
- `window.APP_CONFIG` - `window.APP_CONFIG`
- `window.CURRENT_ROLL_REQUEST`
- `window.CURRENT_ROLL_RESULT`
- Product/article data loading from `/static/article-data.json`. - Product/article data loading from `/static/article-data.json`.
- Direct roll calculations. - Direct roll-diameter request/response rendering.
- Extrapolation calculations. - Extrapolation calculations.
- Forklift/weight warnings. - Forklift/weight warnings.
- Load optimizer UI and calculations. - Load optimizer UI and calculations.
@@ -172,7 +335,11 @@ The direct calculation tab supports multiple modes via mode buttons:
- Product length from core diameter, material thickness, and roll diameter. - Product length from core diameter, material thickness, and roll diameter.
- Product length for a target diameter. - Product length for a target diameter.
The nominal roll diameter formula used in the template is: The direct roll-diameter mode posts raw decision inputs to `/api/calculations/roll`. The authoritative implementation is `calculate_roll()` in `roll_calculation.py`; the template only renders its result.
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 ```text
D = sqrt(d^2 + (4 * L * 1000 * t) / pi) D = sqrt(d^2 + (4 * L * 1000 * t) / pi)
@@ -187,12 +354,31 @@ Where:
If a tolerance/stddev value is present, the UI also displays a `-2σ` and `+2σ` diameter range. If a tolerance/stddev value is present, the UI also displays a `-2σ` and `+2σ` diameter range.
Roll weight is calculated when roll width, area weight, and length are present: Optional roll weight is calculated by the same service when requested and when roll width, area weight, and length are available:
```text ```text
weight_kg = area_weight_g_m2 * length_m * width_m / 1000 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 ### Extrapolation
The extrapolation tab estimates a new roll diameter from a known roll diameter/length pair and a new target length: The extrapolation tab estimates a new roll diameter from a known roll diameter/length pair and a new target length:
@@ -211,7 +397,7 @@ Where:
### Forklift Check ### Forklift Check
`checkForklift()` in `templates/roll_calculator.html` evaluates heavy-roll warnings using `window.APP_CONFIG.forklift_rules`. For the authoritative direct roll-diameter flow, `roll_calculation.py` evaluates the current heavy-roll thresholds and returns structured warnings/notes for the UI and PDF. The legacy browser `checkForklift()` remains in use only by the other client-side direct modes.
The current inline config targets the `bentofix` category and includes: The current inline config targets the `bentofix` category and includes:
@@ -311,6 +497,6 @@ Utility script that updates relative frontend fetch/register paths to Flask-styl
- `access_log.json` grows without rotation or retention limits. - `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. - 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. - RollCalc no longer includes an admin UI/API; article data administration belongs in a separate application.
- There is no visible automated test suite. - Calculation, mutation, UI-integration, PDF-model, and endpoint tests use Python's standard-library `unittest`; endpoint tests require the normal dependencies from `requirements.txt` to be installed.
- The main template is large and mixes layout, styling, data loading, calculations, and UI behavior. - The 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. - The disclaimer confirmation is client-side only and is not persisted or audited server-side.
+192 -6
View File
@@ -3,13 +3,36 @@ Naue Roll Calculator - Beta
Flask app with HTTP Basic Authentication Flask app with HTTP Basic Authentication
""" """
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify from datetime import datetime
from io import BytesIO
import json
import os
from flask import (
Flask,
jsonify,
render_template,
request,
send_file,
send_from_directory,
)
from flask_httpauth import HTTPBasicAuth from flask_httpauth import HTTPBasicAuth
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from functools import wraps
import os from core_presets import CORE_PRESETS
from datetime import datetime from conversation_service import (
import json 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_roll, modify_calculation
app = Flask(__name__) app = Flask(__name__)
auth = HTTPBasicAuth() auth = HTTPBasicAuth()
@@ -74,12 +97,18 @@ def load_build_info():
return build_info return build_info
BUILD_INFO = load_build_info() BUILD_INFO = load_build_info()
CONVERSATION_SERVICE = ConversationService(
OllamaNLUClient(),
build_info=BUILD_INFO,
)
@app.context_processor @app.context_processor
def inject_build_info(): def inject_build_info():
"""Make build metadata available in all templates.""" """Make build metadata available in all templates."""
return {"build_info": BUILD_INFO} return {"build_info": BUILD_INFO}
# ============================================================================ # ============================================================================
# AUTHENTICATION # AUTHENTICATION
# ============================================================================ # ============================================================================
@@ -96,6 +125,7 @@ def verify_password(username, password):
return username return username
return None return None
def log_access(username, endpoint, method, status=200): def log_access(username, endpoint, method, status=200):
"""Log all access attempts for audit trail""" """Log all access attempts for audit trail"""
log_entry = { log_entry = {
@@ -125,7 +155,8 @@ def log_access(username, endpoint, method, status=200):
def index(): def index():
"""Main calculator page - requires authentication""" """Main calculator page - requires authentication"""
log_access(auth.current_user(), "/", "GET") log_access(auth.current_user(), "/", "GET")
return render_template("roll_calculator.html") return render_template("roll_calculator.html", core_presets=CORE_PRESETS)
@app.route("/static/<path:filename>", methods=["GET"]) @app.route("/static/<path:filename>", methods=["GET"])
@auth.login_required @auth.login_required
@@ -134,12 +165,14 @@ def serve_static(filename):
log_access(auth.current_user(), f"/static/{filename}", "GET") log_access(auth.current_user(), f"/static/{filename}", "GET")
return send_from_directory("static", filename) return send_from_directory("static", filename)
@app.route("/api/health", methods=["GET"]) @app.route("/api/health", methods=["GET"])
@auth.login_required @auth.login_required
def health_check(): def health_check():
"""Simple health check endpoint""" """Simple health check endpoint"""
return jsonify({"status": "ok", "version": "14.1"}), 200 return jsonify({"status": "ok", "version": "14.1"}), 200
@app.route("/api/user", methods=["GET"]) @app.route("/api/user", methods=["GET"])
@auth.login_required @auth.login_required
def get_user(): def get_user():
@@ -151,6 +184,156 @@ def get_user():
"timestamp": datetime.now().isoformat() "timestamp": datetime.now().isoformat()
}), 200 }), 200
@app.route("/api/calculations/roll", methods=["POST"])
@auth.login_required
def create_roll_calculation():
"""Resolve and execute a deterministic direct roll-diameter request."""
result = calculate_roll(request.get_json(silent=True), build_info=BUILD_INFO)
log_access(auth.current_user(), "/api/calculations/roll", "POST")
return jsonify(result), 200
@app.route("/api/calculations/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
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 # ERROR HANDLERS
# ============================================================================ # ============================================================================
@@ -160,16 +343,19 @@ def unauthorized(e):
"""Handle 401 Unauthorized - browser will prompt for credentials""" """Handle 401 Unauthorized - browser will prompt for credentials"""
return jsonify({"error": "Unauthorized - please provide valid credentials"}), 401 return jsonify({"error": "Unauthorized - please provide valid credentials"}), 401
@app.errorhandler(404) @app.errorhandler(404)
def not_found(e): def not_found(e):
"""Handle 404 Not Found""" """Handle 404 Not Found"""
return jsonify({"error": "Not found"}), 404 return jsonify({"error": "Not found"}), 404
@app.errorhandler(500) @app.errorhandler(500)
def internal_error(e): def internal_error(e):
"""Handle 500 Internal Server Error""" """Handle 500 Internal Server Error"""
return jsonify({"error": "Internal server error"}), 500 return jsonify({"error": "Internal server error"}), 500
# ============================================================================ # ============================================================================
# STARTUP # STARTUP
# ============================================================================ # ============================================================================
+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)
+2
View File
@@ -1,5 +1,7 @@
# 🚀 RollCalc V14 + QoL Update - Deployment Guide # 🚀 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 ## 📦 Archive Contents
``` ```
+136
View File
@@ -0,0 +1,136 @@
# 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.
## 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.
+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}")
+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())
+747
View File
@@ -0,0 +1,747 @@
"""Deterministic headless domain service for direct roll-diameter calculations."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from difflib import SequenceMatcher
import json
import math
from pathlib import Path
import re
from typing import Any
from core_presets import CORE_PRESETS, core_family, matching_core_presets
ARTICLE_DATA_FILE = Path(__file__).parent / "static" / "article-data.json"
FUZZY_CANDIDATE_THRESHOLD = 0.72
FUZZY_STRONG_THRESHOLD = 0.92
FUZZY_STRONG_MARGIN = 0.08
PRODUCT_FAMILY_ALIASES = {
"stex": "stex",
"secutex": "stex",
"bfix": "bfix",
"bentofix": "bfix",
"sgrid": "sgrid",
"secugrid": "sgrid",
}
REQUEST_FIELDS = {
"intent",
"article_number",
"article_name_hint",
"roll_length_m",
"width_m",
"thickness_mm",
"thickness_stddev_mm",
"area_weight_g_m2",
"core_diameter_mm",
"core_type",
"category",
"production_site",
"include_roll_weight",
}
FORBIDDEN_RESULT_FIELDS = {
"diameter_mm",
"diameters",
"minimum_diameter_mm",
"average_diameter_mm",
"maximum_diameter_mm",
"roll_weight_kg",
"warnings",
"calculation",
}
@dataclass(frozen=True)
class CalculationRequest:
"""Raw decision inputs. It deliberately has no engineering result fields."""
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
include_roll_weight: bool = False
intent: str = "calculate_roll_diameter"
@classmethod
def from_dict(cls, payload: Any) -> "CalculationRequest":
if not isinstance(payload, dict):
raise ValueError("request must be a JSON object")
forbidden = sorted(FORBIDDEN_RESULT_FIELDS.intersection(payload))
if forbidden:
raise ValueError(
"calculated result fields are not accepted: " + ", ".join(forbidden)
)
unknown = sorted(set(payload) - REQUEST_FIELDS)
if unknown:
raise ValueError("unknown request fields: " + ", ".join(unknown))
if payload.get("intent", "calculate_roll_diameter") != "calculate_roll_diameter":
raise ValueError("unsupported intent")
values = dict(payload)
if "article_number" in values and values["article_number"] is not None:
number = values["article_number"]
if not isinstance(number, str):
raise ValueError("article_number must be text")
values["article_number"] = number.strip() or None
for field in ("article_name_hint", "core_type", "category", "production_site"):
if field in values and values[field] is not None:
if not isinstance(values[field], str):
raise ValueError(f"{field} must be text")
values[field] = values[field].strip() or None
numeric_fields = (
"roll_length_m",
"width_m",
"thickness_mm",
"thickness_stddev_mm",
"area_weight_g_m2",
"core_diameter_mm",
)
for field in numeric_fields:
if field in values and values[field] is not None:
value = values[field]
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{field} must be a number")
values[field] = float(value)
if "include_roll_weight" in values and not isinstance(
values["include_roll_weight"], bool
):
raise ValueError("include_roll_weight must be boolean")
return cls(**values)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class CalculationState:
"""Structured conversational state that can be modified field by field."""
request: CalculationRequest
@classmethod
def from_dict(cls, payload: Any) -> "CalculationState":
if not isinstance(payload, dict) or "request" not in payload:
raise ValueError("state.request is required")
unknown = sorted(set(payload) - {"request"})
if unknown:
raise ValueError("unknown state fields: " + ", ".join(unknown))
return cls(request=CalculationRequest.from_dict(payload["request"]))
def to_dict(self) -> dict[str, Any]:
return {"request": self.request.to_dict()}
def apply(self, changes: dict[str, Any]) -> "CalculationState":
if not isinstance(changes, dict) or not changes:
raise ValueError("changes must be a non-empty object")
merged = self.request.to_dict()
if "article_number" in changes and "article_name_hint" not in changes:
merged["article_name_hint"] = None
if "core_type" in changes and "core_diameter_mm" not in changes:
core_type = changes["core_type"]
presets = (
matching_core_presets(core_type)
if isinstance(core_type, str)
else ()
)
if len(presets) == 1:
changes = dict(changes)
changes["core_type"] = presets[0].label
changes["core_diameter_mm"] = presets[0].diameter_mm
else:
merged["core_diameter_mm"] = None
if "core_diameter_mm" in changes and "core_type" not in changes:
merged["core_type"] = None
merged.update(changes)
return CalculationState(CalculationRequest.from_dict(merged))
class ArticleRepository:
def __init__(self, articles: list[dict[str, Any]]):
self.articles = articles
self.by_number: dict[str, list[dict[str, Any]]] = {}
for article in articles:
number = str(article.get("nr", ""))
self.by_number.setdefault(number, []).append(article)
@classmethod
def load(cls, path: Path = ARTICLE_DATA_FILE) -> "ArticleRepository":
with path.open(encoding="utf-8") as handle:
articles = json.load(handle)
if not isinstance(articles, list):
raise ValueError("article data must contain a JSON array")
return cls(articles)
def resolve(
self, article_number: str | None, article_name_hint: str | None
) -> dict[str, Any]:
if article_number:
matches = self.by_number.get(article_number, [])
if not matches:
return {
"status": "article_not_found",
"article_number": article_number,
}
if len(matches) > 1:
candidates = [_article_candidate(item) for item in matches]
return {
"status": "article_ambiguous",
"field": "article",
"reason": "ambiguous_article",
"article_number": article_number,
"candidates": candidates,
"matches": [
{
"number": candidate["article_number"],
"name": candidate["name"],
}
for candidate in candidates
],
}
article = matches[0]
if article_name_hint and not _name_hint_matches(
article_name_hint, str(article.get("name", ""))
):
return {
"status": "article_conflict",
"article_number": article_number,
"article_name_hint": article_name_hint,
"resolved_name": article.get("name", ""),
}
return {"status": "resolved", "article": _public_article(article)}
if article_name_hint:
matches = self._matching_names(article_name_hint)
if len(matches) == 1:
return {"status": "resolved", "article": _public_article(matches[0])}
if len(matches) > 1:
return self._ambiguous_name_resolution(article_name_hint, matches)
fuzzy_matches = self._fuzzy_candidates(article_name_hint)
if fuzzy_matches:
best_score, best_article = fuzzy_matches[0]
second_score = fuzzy_matches[1][0] if len(fuzzy_matches) > 1 else 0.0
if (
best_score >= FUZZY_STRONG_THRESHOLD
and best_score - second_score >= FUZZY_STRONG_MARGIN
):
return {"status": "resolved", "article": _public_article(best_article)}
return self._ambiguous_name_resolution(
article_name_hint,
[article for _, article in fuzzy_matches],
reason="uncertain_article_match",
)
return {
"status": "article_not_found",
"article_name_hint": article_name_hint,
}
return {"status": "not_requested", "article": None}
def _matching_names(self, article_name_hint: str) -> list[dict[str, Any]]:
hint_keys = _article_name_keys(article_name_hint)
if not hint_keys:
return []
return [
article
for article in self.articles
if hint_keys & _article_name_keys(str(article.get("name", "")))
]
def _fuzzy_candidates(
self, article_name_hint: str
) -> list[tuple[float, dict[str, Any]]]:
hint = _base_article_name(article_name_hint)
hint_tokens = set(hint.split())
if len(hint_tokens) < 2:
return []
scored = []
for article in self.articles:
candidate = _base_article_name(str(article.get("name", "")))
candidate_tokens = set(candidate.split())
if not _model_designators_match(hint_tokens, candidate_tokens):
continue
overlap = len(hint_tokens & candidate_tokens) / len(hint_tokens)
similarity = SequenceMatcher(
None,
hint.replace(" ", ""),
candidate.replace(" ", ""),
).ratio()
score = (0.45 * overlap) + (0.55 * similarity)
if score >= FUZZY_CANDIDATE_THRESHOLD:
scored.append((score, article))
return sorted(scored, key=lambda item: (-item[0], str(item[1].get("nr", ""))))[:5]
@staticmethod
def _ambiguous_name_resolution(
article_name_hint: str,
matches: list[dict[str, Any]],
*,
reason: str = "ambiguous_article",
) -> dict[str, Any]:
return {
"status": "article_ambiguous",
"field": "article",
"reason": reason,
"article_name_hint": article_name_hint,
"candidates": [_article_candidate(item) for item in matches],
"matches": [str(item.get("nr", "")) for item in matches],
}
def _canonical_name(value: str) -> str:
value = value.casefold().replace("×", " x ")
tokens = re.findall(r"\d+(?:[.,]\d+)?|[^\W\d_]+", value)
normalized = [
PRODUCT_FAMILY_ALIASES.get(
token.replace(",", "."), token.replace(",", ".")
)
for token in tokens
]
compacted = []
index = 0
while index < len(normalized):
token = normalized[index]
if (
len(token) == 1
and token.isalpha()
and index + 1 < len(normalized)
and normalized[index + 1].isdigit()
):
compacted.append(token + normalized[index + 1])
index += 2
continue
compacted.append(token)
index += 1
return " ".join(compacted)
def _article_name_keys(value: str) -> set[str]:
keys = {_canonical_name(value)}
without_dimensions = re.sub(
r"\s*,?\s*\d{1,2}[,.]\d+\s*[x×]\s*\d+(?:[,.]\d+)?\s*m\s*$",
"",
value,
flags=re.IGNORECASE,
)
keys.add(_canonical_name(without_dimensions))
without_descriptors = re.sub(r"\([^)]*\)", " ", without_dimensions)
keys.add(_canonical_name(without_descriptors))
return {key for key in keys if key}
def _base_article_name(value: str) -> str:
return min(_article_name_keys(value), key=len, default="")
def _model_designators_match(
hint_tokens: set[str], candidate_tokens: set[str]
) -> bool:
models = {token for token in hint_tokens if re.fullmatch(r"[a-z]\d+", token)}
return not models or models <= candidate_tokens
def _name_hint_matches(hint: str, resolved_name: str) -> bool:
hint_tokens = _canonical_name(hint).split()
resolved_tokens = _canonical_name(resolved_name).split()
if not hint_tokens:
return True
return resolved_tokens[: len(hint_tokens)] == hint_tokens
def _article_candidate(article: dict[str, Any]) -> dict[str, str]:
return {
"article_number": str(article.get("nr", "")),
"name": str(article.get("name", "")),
}
def _article_width(name: str) -> float | None:
patterns = (
r"(?<!\d)(\d{1,2}[,.]\d{1,3})\s*[x×]\s*<?\s*\d+(?:[,.]\d+)?\s*m\b",
r"(?<!\d)(\d{1,2}[,.]\d{1,3})\s*m\s*breite\b",
)
widths = {
float(match.replace(",", "."))
for pattern in patterns
for match in re.findall(pattern, name, flags=re.IGNORECASE)
}
valid = {width for width in widths if 0 < width < 100}
return next(iter(valid)) if len(valid) == 1 else None
def _public_article(article: dict[str, Any]) -> dict[str, Any]:
return {
"number": str(article.get("nr", "")),
"name": str(article.get("name", "")),
"thickness_mm": article.get("thickness"),
"thickness_stddev_mm": article.get("thickness_stddev"),
"area_weight_g_m2": article.get("area_weight"),
"core_diameter_mm": article.get("core_type"),
"width_m": _article_width(str(article.get("name", ""))),
}
def _issue_result(
status: str,
request: CalculationRequest,
*,
missing: list[str] | None = None,
ambiguous: list[str] | None = None,
invalid: list[dict[str, str]] | None = None,
article_resolution: dict[str, Any] | None = None,
) -> dict[str, Any]:
result = {
"status": status,
"request": request.to_dict(),
"state": {"request": request.to_dict()},
"missing": missing or [],
"ambiguous": ambiguous or [],
"invalid": invalid or [],
}
if article_resolution is not None:
result["article_resolution"] = article_resolution
return result
def _resolve_core(
diameter: float | None, core_type: str | None
) -> tuple[float | None, str | None, dict[str, Any] | None]:
if core_type:
material = core_family(core_type)
candidates = matching_core_presets(core_type)
if diameter is not None:
matching_diameter = [
preset
for preset in CORE_PRESETS
if math.isclose(preset.diameter_mm, diameter)
]
matching_candidates = [
preset
for preset in candidates
if math.isclose(preset.diameter_mm, diameter)
]
if len(matching_candidates) == 1:
preset = matching_candidates[0]
return preset.diameter_mm, preset.label, None
if candidates:
return None, None, {
"kind": "invalid",
"field": "core_type",
"message": "core type conflicts with core diameter",
}
if material and matching_diameter:
return None, None, {
"kind": "invalid",
"field": "core_type",
"message": "core type conflicts with core diameter",
}
normalized = re.sub(r"\s+", " ", core_type.casefold()).strip()
if normalized not in {"custom", "custom / not specified"}:
return None, None, {
"kind": "clarification",
"field": "core_type",
"message": "unknown core type; provide a known core or diameter",
"options": [preset.label for preset in CORE_PRESETS],
}
label = matching_diameter[0].label if matching_diameter else core_type
return diameter, label, None
if len(candidates) == 1:
return candidates[0].diameter_mm, candidates[0].label, None
return None, None, {
"kind": "clarification",
"field": "core_type",
"message": "core type does not identify one core diameter",
"options": [preset.label for preset in candidates or CORE_PRESETS],
}
if diameter is None:
return None, None, None
matching = [
preset
for preset in CORE_PRESETS
if math.isclose(preset.diameter_mm, diameter)
]
label = matching[0].label if matching else "Custom / not specified"
return diameter, label, None
def calculate_roll(
request: CalculationRequest | dict[str, Any],
*,
article_repository: ArticleRepository | None = None,
build_info: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Resolve, validate, and calculate one direct roll-diameter request."""
if not isinstance(request, CalculationRequest):
try:
request = CalculationRequest.from_dict(request)
except ValueError as error:
return {
"status": "invalid_parameter",
"missing": [],
"ambiguous": [],
"invalid": [{"field": "request", "message": str(error)}],
}
if not isinstance(request, CalculationRequest):
raise TypeError("request must be CalculationRequest or dict")
repository = article_repository or ArticleRepository.load()
resolution = repository.resolve(request.article_number, request.article_name_hint)
if resolution["status"] == "article_not_found":
return _issue_result(
"article_not_found", request, article_resolution=resolution
)
if resolution["status"] == "article_conflict":
return _issue_result(
"article_conflict", request, article_resolution=resolution
)
if resolution["status"] == "article_ambiguous":
ambiguous_field = (
"article_number"
if resolution.get("article_number")
else "article_name_hint"
)
result = _issue_result(
"needs_clarification",
request,
ambiguous=[ambiguous_field],
article_resolution=resolution,
)
result.update({
"field": "article",
"reason": "ambiguous_article",
"candidates": resolution.get("candidates", []),
})
return result
article = resolution.get("article")
effective: dict[str, dict[str, Any]] = {}
def choose(
field: str,
article_field: str | None = None,
*,
zero_is_missing: bool = True,
) -> Any:
value = getattr(request, field)
source = "user"
if value is None and article and article_field:
value = article.get(article_field)
if value == "" or (zero_is_missing and value in (0, 0.0)):
value = None
source = "article_master_data"
if value is not None:
effective[field] = {"value": value, "source": source}
return value
length = choose("roll_length_m")
thickness = choose("thickness_mm", "thickness_mm")
thickness_stddev = choose(
"thickness_stddev_mm",
"thickness_stddev_mm",
zero_is_missing=False,
)
if thickness_stddev is None:
thickness_stddev = 0.0
effective["thickness_stddev_mm"] = {"value": 0.0, "source": "not_available"}
width = choose("width_m", "width_m")
area_weight = choose("area_weight_g_m2", "area_weight_g_m2")
core_diameter = choose("core_diameter_mm", "core_diameter_mm")
core_diameter, core_label, core_issue = _resolve_core(
core_diameter, request.core_type
)
if core_issue and core_issue["kind"] == "clarification":
result = _issue_result(
"needs_clarification", request, ambiguous=[core_issue["field"]]
)
result["clarifications"] = [core_issue]
return result
if core_issue:
return _issue_result("invalid_parameter", request, invalid=[core_issue])
if core_diameter is not None:
source = effective.get("core_diameter_mm", {}).get("source", "core_type")
effective["core_diameter_mm"] = {"value": core_diameter, "source": source}
effective["core_type"] = {"value": core_label, "source": source}
missing = []
for field, value in (
("roll_length_m", length),
("thickness_mm", thickness),
("core_diameter_mm", core_diameter),
):
if value is None:
missing.append(field)
if request.include_roll_weight:
if width is None:
missing.append("width_m")
if area_weight is None:
missing.append("area_weight_g_m2")
if missing:
return _issue_result("needs_clarification", request, missing=missing)
invalid = []
positive_values = (
("roll_length_m", length),
("thickness_mm", thickness),
("core_diameter_mm", core_diameter),
)
if request.include_roll_weight:
positive_values += (("width_m", width), ("area_weight_g_m2", area_weight))
for field, value in positive_values:
if not math.isfinite(value) or value <= 0:
invalid.append({"field": field, "message": "must be greater than zero"})
if not math.isfinite(thickness_stddev) or thickness_stddev < 0:
invalid.append(
{"field": "thickness_stddev_mm", "message": "must be zero or greater"}
)
minimum_thickness = thickness - (2 * thickness_stddev)
if minimum_thickness <= 0:
invalid.append(
{
"field": "thickness_stddev_mm",
"message": "minus two standard deviations must remain positive",
}
)
if invalid:
return _issue_result("invalid_parameter", request, invalid=invalid)
try:
average = math.sqrt(
core_diameter**2 + (4 * length * 1000 * thickness) / math.pi
)
lower = math.sqrt(
core_diameter**2 + (4 * length * 1000 * minimum_thickness) / math.pi
)
upper = math.sqrt(
core_diameter**2
+ (4 * length * 1000 * (thickness + 2 * thickness_stddev))
/ math.pi
)
roll_weight = None
if request.include_roll_weight:
roll_weight = area_weight * length * width / 1000
except OverflowError:
return _issue_result(
"invalid_parameter",
request,
invalid=[
{
"field": "request",
"message": "values are outside the supported calculation range",
}
],
)
calculated_values = (average, lower, upper)
if roll_weight is not None:
calculated_values += (roll_weight,)
if not all(math.isfinite(value) and value > 0 for value in calculated_values):
return _issue_result(
"invalid_parameter",
request,
invalid=[
{
"field": "request",
"message": "values are outside the supported calculation range",
}
],
)
warnings: list[dict[str, str]] = []
notes: list[str] = []
if roll_weight is not None and (request.category or "").casefold() == "bentofix":
if roll_weight > 2750:
warnings.append(
{
"severity": "critical",
"title": "Weight Limit Exceeded",
"message": "Roll weight exceeds 2750 kg maximum",
}
)
elif roll_weight >= 1700:
core_message = (
"Core diameter OK"
if core_diameter >= 170
else f"Core diameter {core_diameter:g} mm (min 170 mm required)"
)
warnings.append(
{
"severity": "warning",
"title": "Heavy Roll – Special Equipment Required",
"message": (
"Special equipment required for rolls ≥ 1700 kg. "
f"{core_message}."
),
}
)
else:
notes.append(
f"Roll weight {roll_weight:.1f} kg is within standard limits. "
"No special equipment required."
)
calculation = {
"effective_roll_length_m": length,
"minimum_diameter_mm": min(lower, upper),
"average_diameter_mm": average,
"maximum_diameter_mm": max(lower, upper),
"roll_weight_kg": roll_weight,
}
return {
"status": "success",
"request": request.to_dict(),
"state": {"request": request.to_dict()},
"resolved_article": article,
"effective_inputs": effective,
"calculation": calculation,
"warnings": warnings,
"notes": notes,
"provenance": {
"calculator": "roll_calculation.calculate_roll",
"build": build_info or {},
},
}
def modify_calculation(
state: CalculationState | dict[str, Any],
changes: dict[str, Any],
*,
article_repository: ArticleRepository | None = None,
build_info: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Apply controlled changes to structured state and recalculate."""
try:
current = (
state
if isinstance(state, CalculationState)
else CalculationState.from_dict(state)
)
modified = current.apply(changes)
except ValueError as error:
return {
"status": "invalid_parameter",
"missing": [],
"ambiguous": [],
"invalid": [{"field": "changes", "message": str(error)}],
}
result = calculate_roll(
modified.request,
article_repository=article_repository,
build_info=build_info,
)
result["state"] = modified.to_dict()
return result
+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())
+214 -22
View File
@@ -43,6 +43,9 @@
.btn-calc:hover { background: #1F5438; } .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 { 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-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 { 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-box.show { display: block; }
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; } .result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
@@ -509,10 +512,9 @@
<span class="unit-label">mm</span> <span class="unit-label">mm</span>
</div> </div>
<div class="core-presets"> <div class="core-presets">
<button class="core-preset-btn" data-core="133">133mm Stahl</button> {% for preset in core_presets %}
<button class="core-preset-btn" data-core="150">150mm PVC</button> <button class="core-preset-btn" data-core="{{ preset.ui_diameter }}" data-core-material="{{ preset.material }}">{{ preset.label }}</button>
<button class="core-preset-btn" data-core="168">168mm PVC</button> {% endfor %}
<button class="core-preset-btn" data-core="194">194mm Stahl</button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -613,6 +615,7 @@
<div class="result-label" id="d-res-label">Result</div> <div class="result-label" id="d-res-label">Result</div>
<div class="result-value" id="d-res-val">–</div> <div class="result-value" id="d-res-val">–</div>
<div class="result-range" id="d-res-range"></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>
<div class="formula-box"> <div class="formula-box">
@@ -880,6 +883,8 @@
// ===================================================== // =====================================================
window.ARTICLE_DATA = []; 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.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 // LOAD ARTICLE DATA
@@ -936,6 +941,7 @@ function populateArticleDropdowns() {
inp.value = val; inp.value = val;
pane.querySelectorAll('.core-preset-btn').forEach(b => b.classList.remove('active')); pane.querySelectorAll('.core-preset-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
if (pane.id === 'tab-direct') invalidatePdfReport();
} }
} }
}); });
@@ -982,7 +988,102 @@ document.querySelectorAll('.mode-btn').forEach(btn => {
// ===================================================== // =====================================================
// DIRECT CALCULATION (WITH 2σ) // 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();
}
document.getElementById('d-calc').addEventListener('click', async () => {
invalidatePdfReport();
const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter'; const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter';
const d = parseFloat(document.getElementById('d-co').value) || 0; const d = parseFloat(document.getElementById('d-co').value) || 0;
const t = parseFloat(document.getElementById('d-th').value) || 0; const t = parseFloat(document.getElementById('d-th').value) || 0;
@@ -995,20 +1096,45 @@ document.getElementById('d-calc').addEventListener('click', () => {
const resLabel = document.getElementById('d-res-label'); const resLabel = document.getElementById('d-res-label');
const resVal = document.getElementById('d-res-val'); const resVal = document.getElementById('d-res-val');
const resRange = document.getElementById('d-res-range'); const resRange = document.getElementById('d-res-range');
let diameterResult = null;
let authoritativeResult = null;
let calculationRequest = null;
if (mode === 'diameter') { if (mode === 'diameter') {
if (!d || !t || !L) { alert('Please enter d, t, and L.'); return; } calculationRequest = buildCalculationRequest();
const D_nom = Math.sqrt(d*d + (4*L*1000*t)/Math.PI); 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)'; resLabel.textContent = 'Roll Diameter (D)';
resVal.textContent = D_nom.toFixed(1) + ' mm'; resVal.textContent = diameterResult.average.toFixed(1) + ' mm';
if (tol) { if (tol) {
const D_minus2sigma = Math.sqrt(d*d + (4*L*1000*(t-2*tol))/Math.PI); resRange.textContent = '−2σ: ' + diameterResult.minus2Sigma.toFixed(1) + ' mm | +2σ: ' + diameterResult.plus2Sigma.toFixed(1) + ' mm';
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';
} else { } else {
resRange.textContent = ''; 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') { } else if (mode === 'length') {
if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; } if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; }
const L_calc = (Math.PI / (4 * t)) * (D*D - d*d) / 1000; const L_calc = (Math.PI / (4 * t)) * (D*D - d*d) / 1000;
@@ -1074,17 +1200,24 @@ document.getElementById('d-calc').addEventListener('click', () => {
document.getElementById('d-wo').value = W_result.toFixed(1); document.getElementById('d-wo').value = W_result.toFixed(1);
} }
if (mode !== 'diameter') {
const wi = parseFloat(document.getElementById('d-wi').value) || 0; const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const aw = parseFloat(document.getElementById('d-aw').value) || 0; const aw = parseFloat(document.getElementById('d-aw').value) || 0;
const L_final = parseFloat(document.getElementById('d-le').value) || 0; const L_final = parseFloat(document.getElementById('d-le').value) || 0;
if (wi > 0 && aw > 0 && L_final > 0) { if (wi > 0 && aw > 0 && L_final > 0) {
const weight = aw * L_final * wi / 1000; const rollWeight = aw * L_final * wi / 1000;
document.getElementById('d-wo').value = weight.toFixed(1); document.getElementById('d-wo').value = rollWeight.toFixed(1);
checkForklift(weight, d, category); checkForklift(rollWeight, d, category);
} else { } else {
document.getElementById('forkliftCheck').classList.remove('show'); 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'; resultBox.style.display = 'block';
}); });
@@ -1096,6 +1229,7 @@ document.getElementById('d-reset').addEventListener('click', () => {
document.getElementById('d-art').value = ''; document.getElementById('d-art').value = '';
document.getElementById('d-result').style.display = 'none'; document.getElementById('d-result').style.display = 'none';
document.getElementById('forkliftCheck').classList.remove('show'); document.getElementById('forkliftCheck').classList.remove('show');
invalidatePdfReport();
}); });
// ===================================================== // =====================================================
@@ -1141,33 +1275,91 @@ function checkForklift(weightKg, coreMm, category) {
const cfg = window.APP_CONFIG?.forklift_rules; const cfg = window.APP_CONFIG?.forklift_rules;
if (!cfg || !cfg.applicable_categories.includes(category.toLowerCase())) { if (!cfg || !cfg.applicable_categories.includes(category.toLowerCase())) {
box.classList.remove('show'); box.classList.remove('show');
return; return null;
} }
const rule = cfg.rules[0]; const rule = cfg.rules[0];
const limit = cfg.hard_limit; const limit = cfg.hard_limit;
if (weightKg > limit.max_weight_kg) { if (weightKg > limit.max_weight_kg) {
const message = limit.messages.en.block_text.replace('{weight}', weightKg);
box.className = 'forklift-check fc-block show'; box.className = 'forklift-check fc-block show';
titleEl.textContent = '⚠️ ' + limit.messages.en.block_title; titleEl.textContent = '⚠️ ' + limit.messages.en.block_title;
bodyEl.innerHTML = '<p>' + limit.messages.en.block_text.replace('{weight}', weightKg) + '</p>'; bodyEl.innerHTML = '<p>' + message + '</p>';
return; return {
kind: 'warning',
severity: 'critical',
title: limit.messages.en.block_title,
message
};
} }
if (weightKg >= rule.weight_threshold_kg) { if (weightKg >= rule.weight_threshold_kg) {
const coreOk = coreMm >= rule.requirements.min_core_outer_diameter_mm; 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'; box.className = 'forklift-check fc-warn show';
titleEl.textContent = '⚠️ ' + rule.messages.en.warning_title; 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>' + message + '</p>';
bodyEl.innerHTML = '<p>Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg:</p><ul>' + statusMsg + '</ul>'; return {
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'; box.className = 'forklift-check fc-ok show';
titleEl.textContent = '✓ Forklift Check OK'; 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 };
} }
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';
}
});
// ===================================================== // =====================================================
// LOAD OPTIMIZER CORE // LOAD OPTIMIZER CORE
// ===================================================== // =====================================================
+67
View File
@@ -0,0 +1,67 @@
import base64
import os
import tempfile
import unittest
import app as rollcalc_app
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)
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()
+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()
+523
View File
@@ -0,0 +1,523 @@
import json
import math
import unittest
from roll_calculation import (
ArticleRepository,
CalculationRequest,
CalculationState,
_canonical_name,
calculate_roll,
modify_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_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_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_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_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()
+44
View File
@@ -0,0 +1,44 @@
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)
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)
if __name__ == "__main__":
unittest.main()