feat: add conversational RollCalc assistant
This commit is contained in:
+78
-5
@@ -4,7 +4,7 @@ Project-specific technical and domain knowledge for RollCalcPython.
|
||||
|
||||
## Purpose
|
||||
|
||||
RollCalcPython is an internal roll calculator for Naue roll products. The backend is a minimal Flask application that provides authentication, routing, static file delivery, and access logs. Most domain behavior runs in the browser inside `templates/roll_calculator.html`.
|
||||
RollCalcPython is an internal roll calculator for Naue roll products. The backend provides authentication, routing, static file delivery, access logs, and the authoritative direct roll-diameter calculation service. Other modes and the load optimizer still contain client-side behavior in `templates/roll_calculator.html`.
|
||||
|
||||
The tool is not intended as an unchecked source of truth. Inputs and outputs must be checked for plausibility before operational use.
|
||||
|
||||
@@ -19,6 +19,12 @@ Backend:
|
||||
- `/static/<path:filename>` is intended to serve static files behind Basic Auth.
|
||||
- `/api/health` returns health/version information.
|
||||
- `/api/user` returns the authenticated user.
|
||||
- `/api/calculations/roll` executes a `CalculationRequest` through the deterministic core.
|
||||
- `/api/calculations/roll/modify` applies controlled changes to `CalculationState` and recalculates.
|
||||
- `/api/reports/roll-calculation.pdf` recalculates a request and renders its authoritative result.
|
||||
- `/api/conversations` creates a process-local conversational session.
|
||||
- `/api/conversations/<id>/messages` runs constrained NLU followed by the existing deterministic calculation functions.
|
||||
- `/api/conversations/reports/<id>.pdf` recalculates a stored input-only request through the existing PDF path.
|
||||
- Access events are appended to `access_log.json`.
|
||||
- `build_info.json` is loaded at startup and exposed to all templates as `build_info`.
|
||||
|
||||
@@ -30,6 +36,47 @@ Frontend:
|
||||
- `window.APP_CONFIG` is currently defined inline for forklift rules.
|
||||
- A disclaimer modal blocks use until the user checks the confirmation checkbox.
|
||||
- The footer displays build metadata: version, branch, commit, and timestamp.
|
||||
- A valid direct diameter request/result is retained as `window.CURRENT_ROLL_REQUEST` / `window.CURRENT_ROLL_RESULT` until an input changes.
|
||||
|
||||
Domain service:
|
||||
|
||||
- `roll_calculation.py` owns `CalculationRequest`, `CalculationState`, `ArticleRepository`, `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
|
||||
|
||||
@@ -79,7 +126,7 @@ Assumptions:
|
||||
- No air gaps.
|
||||
- Roll geometry is derived mathematically, not physically measured in the app.
|
||||
|
||||
Nominal diameter formula used by the Direct Calculation tab:
|
||||
Nominal diameter formula used by `roll_calculation.calculate_roll()` and returned to the Direct Calculation tab:
|
||||
|
||||
```text
|
||||
D = sqrt(d^2 + (4 * L * 1000 * t) / pi)
|
||||
@@ -117,7 +164,7 @@ Variables:
|
||||
|
||||
## Roll Weight
|
||||
|
||||
Roll weight is calculated client-side when width, area weight, and length are available:
|
||||
For the authoritative direct roll-diameter flow, roll weight is calculated server-side when requested and width, area weight, and length are available:
|
||||
|
||||
```text
|
||||
weight_kg = area_weight_g_m2 * length_m * width_m / 1000
|
||||
@@ -125,6 +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.
|
||||
|
||||
## PDF Reports
|
||||
|
||||
The browser and PDF endpoints both submit a `CalculationRequest`. The PDF endpoint recalculates through `calculate_roll()` and passes its `CalculationResult` through `report_from_calculation_result()`; it never accepts client-supplied result fields. Minimum, average, maximum, effective roll length, optional weight, warnings, notes, effective inputs, and provenance therefore come from the same core.
|
||||
|
||||
`POST /api/reports/roll-calculation.pdf` is protected by the existing HTTP Basic Auth. Incomplete, conflicting, or invalid calculation requests return HTTP 400 rather than a document. Generated filenames include date and time without colon characters.
|
||||
|
||||
`pdf_report.py` uses no third-party package. It creates one A4 page with a compact header, result cards, two-column input area, warnings/notes, and footer. Repeated diagonal `INTERNAL USE ONLY` text is drawn into the actual page content stream with reduced opacity; it is a marking/deterrence mechanism and is not tamper-proof or DRM.
|
||||
|
||||
Direct-flow warnings are returned by the domain core and rendered by both consumers. `checkForklift()` remains only for the existing non-headless length/target UI modes. Build metadata and generation time are supplied server-side.
|
||||
|
||||
## Request Resolution and Clarification
|
||||
|
||||
- Article numbers are strings and exact matches take precedence.
|
||||
- Duplicate records for one exact article number require clarification; file order never decides the result.
|
||||
- A supplied name hint is validated conservatively; a clear mismatch produces `article_conflict`.
|
||||
- Article-name normalization in `roll_calculation.py` maps only the product-family tokens `Stex`/`Secutex`, `Bfix`/`Bentofix`, and `Sgrid`/`Secugrid` to their abbreviated canonical forms before deterministic matching. The mappings are token-bounded and do not alter `article-data.json`.
|
||||
- Unknown numbers/names produce `article_not_found`; duplicate exact names require clarification.
|
||||
- The service may extract one unambiguous width from standardized article-name dimensions and marks its source as `article_master_data`.
|
||||
- Required absent values produce `status = needs_clarification` with `missing` fields.
|
||||
- Canonical core presets live in `core_presets.py` and are rendered into the web UI as well as consumed by the headless resolver. The ordered list remains 133 mm steel, 150 mm PVC, 168 mm PVC, and 194 mm steel.
|
||||
- Complete preset labels and finite explicit German/English aliases resolve canonically. Generic `steel`/`Stahlkern` and `PVC`/plastic descriptions remain ambiguous because both families contain two presets; unknown descriptions never select a diameter.
|
||||
- `CalculationState` stores structured request fields. Mutations change only validated fields and always recalculate.
|
||||
- Unknown state fields, including caller-supplied calculation results, are rejected rather than ignored.
|
||||
|
||||
## Article Data
|
||||
|
||||
Active data source:
|
||||
@@ -156,7 +227,7 @@ Known domain caveat from existing project notes:
|
||||
|
||||
## Forklift and Heavy-Roll Rules
|
||||
|
||||
The active template currently evaluates forklift warnings through `checkForklift()` and inline `window.APP_CONFIG`.
|
||||
The direct roll-diameter service evaluates the following active behavior. Other UI modes still evaluate the equivalent inline `window.APP_CONFIG` rules through `checkForklift()`.
|
||||
|
||||
Current inline rule behavior:
|
||||
|
||||
@@ -235,5 +306,7 @@ Login/access logging remains part of RollCalc, but log viewing and article-data
|
||||
- Flask's default static route may conflict with the intended authenticated `/static/<path:filename>` behavior; verify effective routing before relying on protected static files.
|
||||
- Most active logic is concentrated in one large HTML template.
|
||||
- Multiple similar static JavaScript files exist; not all are necessarily active.
|
||||
- No automated test suite is visible.
|
||||
- The deterministic service, state mutation, template integration, PDF adapter/renderer, and Flask endpoints have standard-library `unittest` coverage under `tests/`.
|
||||
- Some docs and filenames in `docs/` may reflect earlier package/archive versions.
|
||||
- Conversation sessions and report references are lost on restart and are not shared across WSGI workers.
|
||||
- Ollama availability/model installation is external runtime state; normal tests use mocks and do not require it.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RollCalcPython
|
||||
|
||||
Flask-based internal roll diameter calculator for Naue roll products. The app is a small authenticated Flask shell around a mostly client-side calculator UI.
|
||||
Flask-based internal roll diameter calculator for Naue roll products. Direct roll-diameter calculations are provided by a deterministic Python domain service and consumed by the authenticated browser UI and PDF path.
|
||||
|
||||
This README is intended for developers maintaining the project, not for end users.
|
||||
|
||||
@@ -11,6 +11,8 @@ This README is intended for developers maintaining the project, not for end user
|
||||
- Flask-HTTPAuth 4.8.0
|
||||
- Vanilla HTML, CSS, and JavaScript
|
||||
- JSON files for product data and forklift/load rules
|
||||
- Dependency-free server-side PDF report rendering
|
||||
- Direct standard-library HTTP integration with a local Ollama service
|
||||
|
||||
Pinned Python dependencies are defined in `requirements.txt`.
|
||||
|
||||
@@ -49,6 +51,16 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
|
||||
```text
|
||||
.
|
||||
├── app.py
|
||||
├── conversation_service.py
|
||||
├── core_presets.py
|
||||
├── ollama_nlu.py
|
||||
├── pdf_report.py
|
||||
├── roll_calculation.py
|
||||
├── rollcalc_chat.py
|
||||
├── integrations/
|
||||
│ └── openwebui/
|
||||
│ ├── README.md
|
||||
│ └── rollcalc_pipe.py
|
||||
├── requirements.txt
|
||||
├── README.md
|
||||
├── 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
|
||||
├── templates/
|
||||
│ └── roll_calculator.html
|
||||
├── tests/
|
||||
│ └── test_pdf_report.py
|
||||
├── static/
|
||||
│ ├── article-data.json
|
||||
│ ├── config.json
|
||||
@@ -89,6 +103,12 @@ Implemented routes:
|
||||
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
|
||||
| `/api/health` | `GET` | Basic Auth | Returns app health and version. |
|
||||
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
|
||||
| `/api/calculations/roll` | `POST` | Basic Auth | Resolves, validates, and executes a direct roll-diameter request. |
|
||||
| `/api/calculations/roll/modify` | `POST` | Basic Auth | Applies explicit changes to structured calculation state and recalculates. |
|
||||
| `/api/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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Local Ollama NLU Adapter
|
||||
|
||||
The local LLM is an NLU adapter only. Engineering calculations remain deterministic.
|
||||
|
||||
`ollama_nlu.py` calls Ollama's `/api/chat` endpoint directly with the Python standard library. The default configuration is:
|
||||
|
||||
```text
|
||||
model: qwen3.5:35B-A3B
|
||||
base URL: http://127.0.0.1:11434
|
||||
temperature: 0.0
|
||||
think: false
|
||||
timeout: 45 seconds
|
||||
stream: false
|
||||
format: strict JSON schema
|
||||
```
|
||||
|
||||
Configuration can be overridden without editing code:
|
||||
|
||||
```bash
|
||||
export ROLLCALC_OLLAMA_URL=http://127.0.0.1:11434
|
||||
export ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B
|
||||
export ROLLCALC_OLLAMA_TIMEOUT_SECONDS=45
|
||||
export ROLLCALC_OLLAMA_TEMPERATURE=0
|
||||
```
|
||||
|
||||
The model receives the user utterance and, after a successful calculation, a minimal summary of the current input state: resolved article reference, roll length, width, core, and weight-request flag. It does not receive formulas, the article/master dataset, calculated diameters or weights, warnings, or PDF data.
|
||||
|
||||
The accepted model output has exactly one of these shapes:
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": null,
|
||||
"core_type": null,
|
||||
"core_diameter_mm": null,
|
||||
"include_roll_weight": false
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {
|
||||
"roll_length_m": 80.0,
|
||||
"include_roll_weight": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "unsupported"
|
||||
}
|
||||
```
|
||||
|
||||
Every optional field must be present in `new_calculation` and use `null` when the user did not supply it. A modification supports one or more explicitly enumerated input fields (`roll_length_m`, `width_m`, `core_type`, `core_diameter_mm`, and `include_roll_weight`, plus the existing article identifiers). Unknown fields, invalid types, markdown-wrapped JSON, and calculated diameter/weight/warning fields are rejected by application validation even if Ollama returns them. All requested changes are validated and applied atomically; an unresolved clarification retains the complete structured patch until it can be applied.
|
||||
|
||||
### Conversation State and Reports
|
||||
|
||||
`conversation_service.py` owns the current `CalculationState`, last deterministic result, and typed pending clarification. It never reconstructs technical state from chat history. When a successful state exists, an identifier-free `new_calculation` interpretation containing only explicit modification-like values is narrowly converted into a state modification; omitted fields are inherited, while an explicit different article or explicit “new calculation” phrase starts fresh. Successful calculations create an unguessable temporary report ID that stores only the validated calculation request. PDF download recalculates that request through `calculate_roll()` and uses the existing renderer.
|
||||
|
||||
The store is an MVP process-local memory store:
|
||||
|
||||
- sessions and report references disappear on restart;
|
||||
- state is not shared between multiple WSGI processes;
|
||||
- no expiration, persistence, or cross-process locking is implemented;
|
||||
- successful report references remain until process restart.
|
||||
|
||||
### OpenWebUI Browser Adapter
|
||||
|
||||
`integrations/openwebui/rollcalc_pipe.py` is a small OpenWebUI Pipe Function.
|
||||
It registers **RollCalc Assistant** as a selectable model and forwards each user
|
||||
message to the existing conversation API. It neither calls a second model nor
|
||||
contains article, formula, warning, clarification, state-mutation, or PDF
|
||||
logic.
|
||||
|
||||
The Pipe keeps a process-local mapping from `(OpenWebUI user ID, chat ID)` to a
|
||||
RollCalc `conversation_id`. Only the API's deterministic `message` is displayed.
|
||||
An API report path is converted into a Markdown download link using a separately
|
||||
configured browser-reachable RollCalc URL.
|
||||
|
||||
Required configuration:
|
||||
|
||||
```bash
|
||||
export ROLLCALC_API_BASE_URL=http://rollcalc.internal:5000
|
||||
export ROLLCALC_PUBLIC_BASE_URL=https://rollcalc.example.internal
|
||||
export ROLLCALC_USERNAME=service-user
|
||||
export ROLLCALC_PASSWORD='set-outside-the-repository'
|
||||
```
|
||||
|
||||
The API credentials are server-side only and are never embedded in report URLs.
|
||||
The browser must authenticate separately against RollCalc's existing Basic-Auth
|
||||
challenge. Installation, network-topology checks, Valve configuration, demo
|
||||
steps, and MVP limitations are documented in
|
||||
`integrations/openwebui/README.md`.
|
||||
|
||||
### Terminal Demo
|
||||
|
||||
Ensure Ollama is running and the configured model is installed, then run:
|
||||
|
||||
```bash
|
||||
python -m rollcalc_chat
|
||||
```
|
||||
|
||||
For validation output containing raw model JSON, validated interpretation, deterministic outcome, and latency:
|
||||
|
||||
```bash
|
||||
python -m rollcalc_chat --debug
|
||||
```
|
||||
|
||||
Optional real-model/manual article-name matrix (run the prompts in one
|
||||
`python -m rollcalc_chat --debug` session):
|
||||
|
||||
| Prompt | Expected deterministic outcome |
|
||||
| --- | --- |
|
||||
| `Berechne Artikel 146900 mit 50 m und einem 150-mm-PVC-Kern.` | Exact article number `146900` resolves. |
|
||||
| `Berechne Stex R 1801, 5,80 x 50 m mit 50 m und einem 150-mm-PVC-Kern.` | The normalized full name resolves to `146900`. |
|
||||
| `Berechne stex r 1801; 5.80 × 50 m mit 50 m und einem 150-mm-PVC-Kern.` | Case and safe punctuation normalization still resolve to `146900`. |
|
||||
| `Berechne Stex R 1801 mit 50 m und einem 150-mm-PVC-Kern.` | Clarification lists `146900` (5,80 x 50 m) and `146910` (6,00 x 50 m). |
|
||||
| `146900` | Resolves the pending article clarification without another Ollama call. |
|
||||
| `Berechne Artikel 180205, Stex R 1801, mit 40 m und einem 150-mm-PVC-Kern.` | Deterministic `article_conflict`; neither identifier is silently preferred. |
|
||||
| `Berechne Nicht vorhandenes Produkt XYZ mit 40 m und einem 150-mm-PVC-Kern.` | Concise deterministic article-not-found response. |
|
||||
|
||||
The model may extract an article number or name, but all resolution and every
|
||||
displayed candidate come from `static/article-data.json`.
|
||||
|
||||
The initial scope supports new direct roll-diameter calculations, multi-field modifications, and clarification replies. Product-length mode, target-diameter mode, extrapolation, load optimization, general questions, and arbitrary tool execution return `unsupported`.
|
||||
|
||||
The conversational adapter does not reuse the browser's visible 150 mm core default. Current article data contains no usable core mapping, so a request without an explicit known core or core diameter asks for clarification. Width remains optional for diameter-only calculations and becomes required only when roll weight is explicitly requested.
|
||||
|
||||
Normal tests mock Ollama. To opt into the real-model smoke test explicitly:
|
||||
|
||||
```bash
|
||||
ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B \
|
||||
ROLLCALC_RUN_OLLAMA_SMOKE=1 python -m unittest discover \
|
||||
-s tests -p 'test_ollama_real_smoke.py' -v
|
||||
```
|
||||
|
||||
## Frontend Entry Point
|
||||
|
||||
The active UI is `templates/roll_calculator.html`.
|
||||
@@ -121,8 +282,10 @@ The template contains:
|
||||
- Global state:
|
||||
- `window.ARTICLE_DATA`
|
||||
- `window.APP_CONFIG`
|
||||
- `window.CURRENT_ROLL_REQUEST`
|
||||
- `window.CURRENT_ROLL_RESULT`
|
||||
- Product/article data loading from `/static/article-data.json`.
|
||||
- Direct roll calculations.
|
||||
- Direct roll-diameter request/response rendering.
|
||||
- Extrapolation calculations.
|
||||
- Forklift/weight warnings.
|
||||
- Load optimizer UI and calculations.
|
||||
@@ -172,7 +335,11 @@ The direct calculation tab supports multiple modes via mode buttons:
|
||||
- Product length from core diameter, material thickness, and roll diameter.
|
||||
- Product length for a target diameter.
|
||||
|
||||
The nominal roll diameter formula used in the template is:
|
||||
The direct roll-diameter mode posts raw decision inputs to `/api/calculations/roll`. The 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
|
||||
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.
|
||||
|
||||
Roll weight is calculated when roll width, area weight, and length are present:
|
||||
Optional roll weight is calculated by the same service when requested and when roll width, area weight, and length are available:
|
||||
|
||||
```text
|
||||
weight_kg = area_weight_g_m2 * length_m * width_m / 1000
|
||||
```
|
||||
|
||||
In the browser, weight remains optional: the direct-diameter request asks for a weight only when both width and area weight are present. A future machine client can request it explicitly with `include_roll_weight`; missing required weight inputs then produce structured clarification.
|
||||
|
||||
### PDF Calculation Report
|
||||
|
||||
After a valid direct roll-diameter calculation, the result card enables **Download PDF**. The browser posts the original `CalculationRequest`, not calculated result values. The server calls the same `calculate_roll()` service, adapts that authoritative `CalculationResult`, and renders it. Caller-supplied diameters, weights, and warnings are rejected as request fields.
|
||||
|
||||
The A4 report contains:
|
||||
|
||||
- article/reference data when present;
|
||||
- all material calculation inputs, including category/site when selected;
|
||||
- minimum, average, and maximum diameter plus the effective calculated roll length, using the UI's values and rounding;
|
||||
- optional calculated roll weight;
|
||||
- applicable forklift warnings or notes;
|
||||
- generation time and build metadata.
|
||||
|
||||
PDF rendering is implemented in `pdf_report.py` using native PDF page and content primitives. It requires no browser installation and no additional Python package. The repeated diagonal `INTERNAL USE ONLY` marks are painted into the page content stream. They are a visible internal-use deterrent, not DRM or a cryptographically tamper-proof control.
|
||||
|
||||
The filename includes the article reference and local generation time as `roll-calculation_<article>_YYYY-MM-DD_HHMMSS.pdf`. The button is invalidated when a calculation input changes and is currently enabled for the normal direct roll-diameter mode.
|
||||
|
||||
### Extrapolation
|
||||
|
||||
The extrapolation tab estimates a new roll diameter from a known roll diameter/length pair and a new target length:
|
||||
@@ -211,7 +397,7 @@ Where:
|
||||
|
||||
### Forklift Check
|
||||
|
||||
`checkForklift()` in `templates/roll_calculator.html` evaluates heavy-roll warnings using `window.APP_CONFIG.forklift_rules`.
|
||||
For the authoritative direct roll-diameter flow, `roll_calculation.py` evaluates the current heavy-roll thresholds and returns structured warnings/notes for the UI and PDF. The legacy browser `checkForklift()` remains in use only by the other client-side direct modes.
|
||||
|
||||
The current inline config targets the `bentofix` category and includes:
|
||||
|
||||
@@ -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.
|
||||
- The custom `/static/<path:filename>` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection.
|
||||
- RollCalc no longer includes an admin UI/API; article data administration belongs in a separate application.
|
||||
- There is no visible automated test suite.
|
||||
- Calculation, mutation, UI-integration, PDF-model, and endpoint tests use Python's standard-library `unittest`; endpoint tests require the normal dependencies from `requirements.txt` to be installed.
|
||||
- The main template is large and mixes layout, styling, data loading, calculations, and UI behavior.
|
||||
- The disclaimer confirmation is client-side only and is not persisted or audited server-side.
|
||||
|
||||
@@ -3,13 +3,36 @@ Naue Roll Calculator - Beta
|
||||
Flask app with HTTP Basic Authentication
|
||||
"""
|
||||
|
||||
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
jsonify,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
send_from_directory,
|
||||
)
|
||||
from flask_httpauth import HTTPBasicAuth
|
||||
from werkzeug.security import check_password_hash
|
||||
from functools import wraps
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from core_presets import CORE_PRESETS
|
||||
from conversation_service import (
|
||||
ConversationNotFoundError,
|
||||
ConversationReportNotFoundError,
|
||||
ConversationService,
|
||||
)
|
||||
from ollama_nlu import OllamaNLUClient
|
||||
from pdf_report import (
|
||||
ReportValidationError,
|
||||
report_from_calculation_result,
|
||||
render_roll_report,
|
||||
safe_report_filename,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
|
||||
app = Flask(__name__)
|
||||
auth = HTTPBasicAuth()
|
||||
@@ -74,12 +97,18 @@ def load_build_info():
|
||||
return build_info
|
||||
|
||||
BUILD_INFO = load_build_info()
|
||||
CONVERSATION_SERVICE = ConversationService(
|
||||
OllamaNLUClient(),
|
||||
build_info=BUILD_INFO,
|
||||
)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_build_info():
|
||||
"""Make build metadata available in all templates."""
|
||||
return {"build_info": BUILD_INFO}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION
|
||||
# ============================================================================
|
||||
@@ -96,6 +125,7 @@ def verify_password(username, password):
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
def log_access(username, endpoint, method, status=200):
|
||||
"""Log all access attempts for audit trail"""
|
||||
log_entry = {
|
||||
@@ -125,7 +155,8 @@ def log_access(username, endpoint, method, status=200):
|
||||
def index():
|
||||
"""Main calculator page - requires authentication"""
|
||||
log_access(auth.current_user(), "/", "GET")
|
||||
return render_template("roll_calculator.html")
|
||||
return render_template("roll_calculator.html", core_presets=CORE_PRESETS)
|
||||
|
||||
|
||||
@app.route("/static/<path:filename>", methods=["GET"])
|
||||
@auth.login_required
|
||||
@@ -134,12 +165,14 @@ def serve_static(filename):
|
||||
log_access(auth.current_user(), f"/static/{filename}", "GET")
|
||||
return send_from_directory("static", filename)
|
||||
|
||||
|
||||
@app.route("/api/health", methods=["GET"])
|
||||
@auth.login_required
|
||||
def health_check():
|
||||
"""Simple health check endpoint"""
|
||||
return jsonify({"status": "ok", "version": "14.1"}), 200
|
||||
|
||||
|
||||
@app.route("/api/user", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_user():
|
||||
@@ -151,6 +184,156 @@ def get_user():
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/roll", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_roll_calculation():
|
||||
"""Resolve and execute a deterministic direct roll-diameter request."""
|
||||
result = calculate_roll(request.get_json(silent=True), build_info=BUILD_INFO)
|
||||
log_access(auth.current_user(), "/api/calculations/roll", "POST")
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@app.route("/api/calculations/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
|
||||
# ============================================================================
|
||||
@@ -160,16 +343,19 @@ def unauthorized(e):
|
||||
"""Handle 401 Unauthorized - browser will prompt for credentials"""
|
||||
return jsonify({"error": "Unauthorized - please provide valid credentials"}), 401
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
"""Handle 404 Not Found"""
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
"""Handle 500 Internal Server Error"""
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STARTUP
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
"""Application-owned conversational workflow around deterministic RollCalc APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
import math
|
||||
import secrets
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from core_presets import CorePreset, core_family, matching_core_presets
|
||||
from ollama_nlu import (
|
||||
ClarificationInterpretation,
|
||||
NLUError,
|
||||
NLUInterpretation,
|
||||
NLUResponse,
|
||||
OllamaNLUClient,
|
||||
parse_clarification_reply,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
|
||||
|
||||
class ConversationNotFoundError(KeyError):
|
||||
"""Raised when an in-memory conversation ID is unknown."""
|
||||
|
||||
|
||||
class ConversationReportNotFoundError(KeyError):
|
||||
"""Raised when an in-memory report ID is unknown."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArticleCandidate:
|
||||
article_number: str
|
||||
name: str
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"article_number": self.article_number,
|
||||
"name": self.name,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PendingClarification:
|
||||
field: str
|
||||
reason: str
|
||||
core_family: str | None = None
|
||||
candidates: tuple[CorePreset, ...] = ()
|
||||
article_candidates: tuple[ArticleCandidate, ...] = ()
|
||||
requested_changes: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def allowed_values(self) -> tuple[float, ...]:
|
||||
return tuple(preset.diameter_mm for preset in self.candidates)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = {
|
||||
"field": self.field,
|
||||
"reason": self.reason,
|
||||
"core_family": self.core_family,
|
||||
"allowed_values": list(self.allowed_values),
|
||||
"candidates": [
|
||||
{
|
||||
"key": preset.key,
|
||||
"label": preset.label,
|
||||
"diameter_mm": preset.diameter_mm,
|
||||
"material": preset.material,
|
||||
}
|
||||
for preset in self.candidates
|
||||
],
|
||||
}
|
||||
if self.requested_changes:
|
||||
result["requested_changes"] = deepcopy(self.requested_changes)
|
||||
if self.reason == "ambiguous_article":
|
||||
result.pop("core_family")
|
||||
result.pop("allowed_values")
|
||||
result["candidates"] = [
|
||||
candidate.to_dict() for candidate in self.article_candidates
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationRecord:
|
||||
calculation_state: dict[str, Any] | None = None
|
||||
last_result: dict[str, Any] | None = None
|
||||
pending_clarification: PendingClarification | None = None
|
||||
|
||||
@property
|
||||
def pending_fields(self) -> tuple[str, ...]:
|
||||
if self.pending_clarification is None:
|
||||
return ()
|
||||
return (self.pending_clarification.field,)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredReport:
|
||||
request: dict[str, Any]
|
||||
conversation_id: str
|
||||
|
||||
|
||||
class InMemoryConversationStore:
|
||||
"""Process-local MVP storage; all values are copied across its boundary."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._conversations: dict[str, ConversationRecord] = {}
|
||||
self._reports: dict[str, StoredReport] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def create(self) -> str:
|
||||
conversation_id = secrets.token_urlsafe(18)
|
||||
with self._lock:
|
||||
self._conversations[conversation_id] = ConversationRecord()
|
||||
return conversation_id
|
||||
|
||||
def get(self, conversation_id: str) -> ConversationRecord:
|
||||
with self._lock:
|
||||
record = self._conversations.get(conversation_id)
|
||||
if record is None:
|
||||
raise ConversationNotFoundError(conversation_id)
|
||||
return deepcopy(record)
|
||||
|
||||
def save(self, conversation_id: str, record: ConversationRecord) -> None:
|
||||
with self._lock:
|
||||
if conversation_id not in self._conversations:
|
||||
raise ConversationNotFoundError(conversation_id)
|
||||
self._conversations[conversation_id] = deepcopy(record)
|
||||
|
||||
def add_report(self, conversation_id: str, request: dict[str, Any]) -> str:
|
||||
report_id = secrets.token_urlsafe(18)
|
||||
with self._lock:
|
||||
if conversation_id not in self._conversations:
|
||||
raise ConversationNotFoundError(conversation_id)
|
||||
self._reports[report_id] = StoredReport(
|
||||
request=deepcopy(request),
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
return report_id
|
||||
|
||||
def get_report(self, report_id: str) -> StoredReport:
|
||||
with self._lock:
|
||||
report = self._reports.get(report_id)
|
||||
if report is None:
|
||||
raise ConversationReportNotFoundError(report_id)
|
||||
return deepcopy(report)
|
||||
|
||||
|
||||
def _calculation_request(interpretation: NLUInterpretation) -> dict[str, Any]:
|
||||
payload = interpretation.to_dict()
|
||||
payload["intent"] = "calculate_roll_diameter"
|
||||
return payload
|
||||
|
||||
|
||||
def _has_valid_calculation(record: ConversationRecord) -> bool:
|
||||
return (
|
||||
record.calculation_state is not None
|
||||
and isinstance(record.last_result, dict)
|
||||
and record.last_result.get("status") == "success"
|
||||
)
|
||||
|
||||
|
||||
def _current_nlu_context(record: ConversationRecord) -> dict[str, Any] | None:
|
||||
if not _has_valid_calculation(record):
|
||||
return None
|
||||
request = record.calculation_state.get("request", {})
|
||||
if not isinstance(request, dict):
|
||||
return None
|
||||
fields = (
|
||||
"article_number",
|
||||
"article_name_hint",
|
||||
"roll_length_m",
|
||||
"width_m",
|
||||
"core_type",
|
||||
"core_diameter_mm",
|
||||
"include_roll_weight",
|
||||
)
|
||||
resolved_article = record.last_result.get("resolved_article", {})
|
||||
resolved_name = (
|
||||
resolved_article.get("name")
|
||||
if isinstance(resolved_article, dict)
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"has_successful_calculation": True,
|
||||
"resolved_article_name": resolved_name,
|
||||
**{field: request.get(field) for field in fields},
|
||||
}
|
||||
|
||||
|
||||
def _new_calculation_followup_changes(
|
||||
interpretation: NLUInterpretation,
|
||||
record: ConversationRecord,
|
||||
message: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if interpretation.intent != "new_calculation" or not _has_valid_calculation(record):
|
||||
return None
|
||||
if interpretation.article_number is not None:
|
||||
return None
|
||||
if interpretation.article_name_hint is not None:
|
||||
return None
|
||||
normalized_message = " ".join(message.casefold().split())
|
||||
if any(
|
||||
marker in normalized_message
|
||||
for marker in (
|
||||
"neue berechnung",
|
||||
"neue rollenberechnung",
|
||||
"new calculation",
|
||||
"start over",
|
||||
)
|
||||
):
|
||||
return None
|
||||
|
||||
changes = {}
|
||||
for field in (
|
||||
"roll_length_m",
|
||||
"width_m",
|
||||
"core_type",
|
||||
"core_diameter_mm",
|
||||
):
|
||||
value = getattr(interpretation, field)
|
||||
if value is not None:
|
||||
changes[field] = value
|
||||
if interpretation.include_roll_weight:
|
||||
changes["include_roll_weight"] = True
|
||||
return changes or None
|
||||
|
||||
|
||||
def _pending_clarification(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
requested_changes: dict[str, Any] | None = None,
|
||||
) -> PendingClarification | None:
|
||||
missing = result.get("missing", [])
|
||||
ambiguous = result.get("ambiguous", [])
|
||||
for field in (
|
||||
"width_m",
|
||||
"core_diameter_mm",
|
||||
"roll_length_m",
|
||||
"thickness_mm",
|
||||
"area_weight_g_m2",
|
||||
):
|
||||
if field in missing:
|
||||
return PendingClarification(
|
||||
field,
|
||||
"missing_parameter",
|
||||
requested_changes=deepcopy(requested_changes or {}),
|
||||
)
|
||||
if "core_type" in ambiguous:
|
||||
request = result.get("request", {})
|
||||
core_type = request.get("core_type") if isinstance(request, dict) else None
|
||||
if isinstance(core_type, str):
|
||||
candidates = matching_core_presets(core_type)
|
||||
family = core_family(core_type)
|
||||
if len(candidates) > 1 and family is not None:
|
||||
return PendingClarification(
|
||||
field="core_diameter_mm",
|
||||
reason="ambiguous_core_preset",
|
||||
core_family=family,
|
||||
candidates=candidates,
|
||||
requested_changes=deepcopy(requested_changes or {}),
|
||||
)
|
||||
return PendingClarification(
|
||||
"core_diameter_mm",
|
||||
"ambiguous_core_type",
|
||||
requested_changes=deepcopy(requested_changes or {}),
|
||||
)
|
||||
if "article" in ambiguous or any(
|
||||
field in ambiguous for field in ("article_name_hint", "article_number")
|
||||
):
|
||||
resolution = result.get("article_resolution", {})
|
||||
raw_candidates = (
|
||||
resolution.get("candidates", [])
|
||||
if isinstance(resolution, dict)
|
||||
else []
|
||||
)
|
||||
candidates = tuple(
|
||||
ArticleCandidate(
|
||||
article_number=item["article_number"],
|
||||
name=item["name"],
|
||||
)
|
||||
for item in raw_candidates
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("article_number"), str)
|
||||
and isinstance(item.get("name"), str)
|
||||
)
|
||||
return PendingClarification(
|
||||
"article",
|
||||
"ambiguous_article",
|
||||
article_candidates=candidates,
|
||||
requested_changes=deepcopy(requested_changes or {}),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _format_diameter_choices(values: tuple[float, ...]) -> str:
|
||||
labels = [f"{value:g} mm" for value in values]
|
||||
if len(labels) < 2:
|
||||
return labels[0] if labels else ""
|
||||
if len(labels) == 2:
|
||||
return f"{labels[0]} oder {labels[1]}"
|
||||
return f"{', '.join(labels[:-1])} und {labels[-1]}"
|
||||
|
||||
|
||||
def _core_family_names(family: str | None) -> tuple[str, str]:
|
||||
if family == "steel":
|
||||
return "Stahlkern", "Stahlkerne"
|
||||
if family == "pvc":
|
||||
return "PVC-Kern", "PVC-Kerne"
|
||||
return "Kern", "Kerne"
|
||||
|
||||
|
||||
def ambiguous_core_message(pending: PendingClarification) -> str:
|
||||
_, plural = _core_family_names(pending.core_family)
|
||||
choices = _format_diameter_choices(pending.allowed_values)
|
||||
return (
|
||||
f"Es sind mehrere {plural} hinterlegt. "
|
||||
f"Welchen Kerndurchmesser soll ich verwenden: {choices}?"
|
||||
)
|
||||
|
||||
|
||||
def unavailable_core_diameter_message(
|
||||
pending: PendingClarification,
|
||||
value: float,
|
||||
) -> str:
|
||||
singular, _ = _core_family_names(pending.core_family)
|
||||
choices = _format_diameter_choices(pending.allowed_values).replace(
|
||||
" oder ",
|
||||
" und ",
|
||||
)
|
||||
return (
|
||||
f"{value:g} mm ist für einen {singular} nicht hinterlegt. "
|
||||
f"Verfügbar sind {choices}."
|
||||
)
|
||||
|
||||
|
||||
def ambiguous_article_message(pending: PendingClarification) -> str:
|
||||
choices = "\noder\n".join(
|
||||
f"{candidate.article_number} – {' '.join(candidate.name.split())}"
|
||||
for candidate in pending.article_candidates
|
||||
)
|
||||
return (
|
||||
"Ich habe mehrere passende Artikel gefunden. Welchen meinst Du:\n"
|
||||
f"{choices}?"
|
||||
)
|
||||
|
||||
|
||||
def incompatible_clarification_message(field: str) -> str:
|
||||
messages = {
|
||||
"core_diameter_mm": (
|
||||
"Ich benötige den Kerndurchmesser in Millimetern, z. B. 150 mm."
|
||||
),
|
||||
"roll_length_m": "Ich benötige die Rollenlänge in Metern, z. B. 80 m.",
|
||||
"width_m": "Ich benötige die Breite in Metern, z. B. 4,90 m.",
|
||||
"thickness_mm": "Ich benötige die Materialdicke in Millimetern.",
|
||||
"area_weight_g_m2": "Ich benötige das Flächengewicht in g/m².",
|
||||
"article_number": "Bitte gib die genaue Artikelnummer an.",
|
||||
"article": "Bitte wähle eine der genannten Artikelnummern aus.",
|
||||
}
|
||||
return messages.get(field, "Die Antwort passt nicht zur angeforderten Angabe.")
|
||||
|
||||
|
||||
def clarification_message(
|
||||
result: dict[str, Any],
|
||||
pending: PendingClarification | None = None,
|
||||
) -> str:
|
||||
"""Map deterministic result statuses to deterministic German messages."""
|
||||
status = result.get("status")
|
||||
if status == "article_not_found":
|
||||
return "Der angegebene Artikel wurde nicht gefunden."
|
||||
if status == "article_conflict":
|
||||
return (
|
||||
"Artikelnummer und Produktbezeichnung passen nicht zusammen. "
|
||||
"Bitte prüfe die Angaben."
|
||||
)
|
||||
if status == "invalid_parameter":
|
||||
invalid = result.get("invalid", [])
|
||||
if invalid and isinstance(invalid[0], dict):
|
||||
field = invalid[0].get("field", "Eingabe")
|
||||
detail = invalid[0].get("message", "ungültiger Wert")
|
||||
return f"Die Eingabe für {field} ist ungültig: {detail}."
|
||||
return "Die Eingaben sind ungültig. Bitte prüfe die Angaben."
|
||||
|
||||
missing = result.get("missing", [])
|
||||
ambiguous = result.get("ambiguous", [])
|
||||
if "width_m" in missing:
|
||||
return (
|
||||
"Für diesen Artikel ist keine eindeutige Breite verfügbar. "
|
||||
"Welche Breite soll ich verwenden?"
|
||||
)
|
||||
if "core_diameter_mm" in missing:
|
||||
return "Welchen Kerndurchmesser soll ich verwenden?"
|
||||
if "roll_length_m" in missing:
|
||||
return "Welche Rollenlänge soll ich verwenden?"
|
||||
if "thickness_mm" in missing:
|
||||
return (
|
||||
"Für diese Berechnung ist keine Materialdicke verfügbar. "
|
||||
"Bitte gib die Materialdicke an."
|
||||
)
|
||||
if "area_weight_g_m2" in missing:
|
||||
return (
|
||||
"Für die Gewichtsberechnung ist kein Flächengewicht verfügbar. "
|
||||
"Bitte gib das Flächengewicht an."
|
||||
)
|
||||
if "core_type" in ambiguous:
|
||||
if pending is not None and pending.reason == "ambiguous_core_preset":
|
||||
return ambiguous_core_message(pending)
|
||||
request = result.get("request", {})
|
||||
core_type = request.get("core_type") if isinstance(request, dict) else None
|
||||
family = core_family(core_type) if isinstance(core_type, str) else None
|
||||
if family == "steel":
|
||||
return (
|
||||
"Es sind mehrere Stahlkern-Presets vorhanden. "
|
||||
"Bitte gib den Kerndurchmesser an."
|
||||
)
|
||||
if family == "pvc":
|
||||
return (
|
||||
"Es sind mehrere PVC-Kern-Presets vorhanden. "
|
||||
"Bitte gib den Kerndurchmesser an."
|
||||
)
|
||||
return "Der Kerntyp ist nicht eindeutig. Bitte gib den Kerndurchmesser an."
|
||||
if pending is not None and pending.reason == "ambiguous_article":
|
||||
return ambiguous_article_message(pending)
|
||||
if any(
|
||||
field in ambiguous
|
||||
for field in ("article", "article_name_hint", "article_number")
|
||||
):
|
||||
return "Der Artikel ist nicht eindeutig. Bitte gib die genaue Artikelnummer an."
|
||||
return "Für die Berechnung fehlen eindeutige Angaben. Bitte ergänze die Eingaben."
|
||||
|
||||
|
||||
def success_message(result: dict[str, Any], pdf_url: str) -> str:
|
||||
"""Render only authoritative result values using a fixed response template."""
|
||||
article = result.get("resolved_article") or {}
|
||||
calculation = result["calculation"]
|
||||
number = article.get("number") or "ohne Artikelnummer"
|
||||
name = article.get("name") or "ohne Produktbezeichnung"
|
||||
lines = [
|
||||
"Berechnung abgeschlossen.",
|
||||
"",
|
||||
f"Artikel: {name} ({number})",
|
||||
f"Rollenlänge: {calculation['effective_roll_length_m']:.2f} m",
|
||||
]
|
||||
effective_width = result.get("effective_inputs", {}).get("width_m")
|
||||
if isinstance(effective_width, dict) and effective_width.get("value") is not None:
|
||||
lines.append(f"Breite: {effective_width['value']:.2f} m")
|
||||
lines.extend((
|
||||
f"Minimum: {calculation['minimum_diameter_mm']:.1f} mm",
|
||||
f"Mittelwert: {calculation['average_diameter_mm']:.1f} mm",
|
||||
f"Maximum: {calculation['maximum_diameter_mm']:.1f} mm",
|
||||
))
|
||||
if calculation.get("roll_weight_kg") is not None:
|
||||
lines.append(f"Rollengewicht: {calculation['roll_weight_kg']:.1f} kg")
|
||||
for warning in result.get("warnings", []):
|
||||
lines.append(f"Warnung: {warning['title']} – {warning['message']}")
|
||||
for note in result.get("notes", []):
|
||||
lines.append(f"Hinweis: {note}")
|
||||
lines.extend(("", f"PDF: {pdf_url}"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class ConversationService:
|
||||
def __init__(
|
||||
self,
|
||||
nlu_client: OllamaNLUClient,
|
||||
*,
|
||||
store: InMemoryConversationStore | None = None,
|
||||
build_info: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.nlu_client = nlu_client
|
||||
self.store = store or InMemoryConversationStore()
|
||||
self.build_info = build_info or {}
|
||||
|
||||
def create_conversation(self) -> str:
|
||||
return self.store.create()
|
||||
|
||||
def conversation_state(self, conversation_id: str) -> ConversationRecord:
|
||||
return self.store.get(conversation_id)
|
||||
|
||||
def report_request(self, report_id: str) -> dict[str, Any]:
|
||||
return self.store.get_report(report_id).request
|
||||
|
||||
def handle_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
message: str,
|
||||
*,
|
||||
include_diagnostics: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
record = self.store.get(conversation_id)
|
||||
clarification: ClarificationInterpretation | None = None
|
||||
nlu: NLUResponse | None = None
|
||||
applied_as_modification = False
|
||||
pending = record.pending_clarification
|
||||
had_valid_result = (
|
||||
isinstance(record.last_result, dict)
|
||||
and record.last_result.get("status") == "success"
|
||||
)
|
||||
if pending is not None:
|
||||
expected_field = pending.field
|
||||
selected_article: ArticleCandidate | None = None
|
||||
if pending.reason == "ambiguous_article":
|
||||
article_number = message.strip()
|
||||
selected_article = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in pending.article_candidates
|
||||
if candidate.article_number == article_number
|
||||
),
|
||||
None,
|
||||
)
|
||||
clarification = ClarificationInterpretation(
|
||||
"parsed" if selected_article is not None else "incompatible",
|
||||
expected_field,
|
||||
selected_article.article_number if selected_article else None,
|
||||
)
|
||||
else:
|
||||
clarification = parse_clarification_reply(message, expected_field)
|
||||
if clarification.status != "parsed":
|
||||
response = {
|
||||
"status": "needs_clarification",
|
||||
"message": (
|
||||
ambiguous_article_message(pending)
|
||||
if pending.reason == "ambiguous_article"
|
||||
else incompatible_clarification_message(expected_field)
|
||||
),
|
||||
"clarification": clarification.to_dict(),
|
||||
"pending_clarification": pending.to_dict(),
|
||||
"result": record.last_result,
|
||||
}
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
|
||||
if record.calculation_state is None:
|
||||
return {
|
||||
"status": "needs_calculation",
|
||||
"message": (
|
||||
"Es gibt noch keine Berechnung, die ergänzt werden kann. "
|
||||
"Bitte starte zuerst eine neue Berechnung."
|
||||
),
|
||||
}
|
||||
if pending.reason == "ambiguous_article":
|
||||
changes = dict(pending.requested_changes)
|
||||
changes["article_number"] = clarification.value
|
||||
else:
|
||||
changes = dict(pending.requested_changes)
|
||||
changes[expected_field] = clarification.value
|
||||
if pending.reason == "ambiguous_core_preset":
|
||||
preset = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in pending.candidates
|
||||
if math.isclose(
|
||||
candidate.diameter_mm,
|
||||
clarification.value,
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if preset is None:
|
||||
response = {
|
||||
"status": "needs_clarification",
|
||||
"message": unavailable_core_diameter_message(
|
||||
pending,
|
||||
clarification.value,
|
||||
),
|
||||
"clarification": {
|
||||
"status": "invalid_value",
|
||||
"field": pending.field,
|
||||
"value": clarification.value,
|
||||
},
|
||||
"pending_clarification": pending.to_dict(),
|
||||
"result": record.last_result,
|
||||
}
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
changes.update({
|
||||
"core_type": preset.label,
|
||||
"core_diameter_mm": preset.diameter_mm,
|
||||
})
|
||||
elif expected_field == "core_diameter_mm":
|
||||
current_request = record.calculation_state.get("request", {})
|
||||
current_core_type = current_request.get("core_type")
|
||||
if current_core_type is not None:
|
||||
changes["core_type"] = current_core_type
|
||||
result = modify_calculation(
|
||||
record.calculation_state,
|
||||
changes,
|
||||
build_info=self.build_info,
|
||||
)
|
||||
applied_as_modification = True
|
||||
else:
|
||||
try:
|
||||
nlu = self.nlu_client.interpret(
|
||||
message,
|
||||
has_state=record.calculation_state is not None,
|
||||
expected_fields=(),
|
||||
current_state=_current_nlu_context(record),
|
||||
)
|
||||
except NLUError as error:
|
||||
return {
|
||||
"status": "nlu_error",
|
||||
"error": error.__class__.__name__,
|
||||
"message": (
|
||||
"Die Spracheingabe konnte nicht sicher verarbeitet werden. "
|
||||
"Bitte versuche es erneut oder formuliere die Angaben expliziter."
|
||||
),
|
||||
}
|
||||
|
||||
interpretation = nlu.interpretation
|
||||
if interpretation.intent == "unsupported":
|
||||
response = {
|
||||
"status": "unsupported",
|
||||
"message": (
|
||||
"Diese Anfrage wird derzeit nicht unterstützt. Möglich sind neue "
|
||||
"Rollendurchmesser-Berechnungen und Änderungen einer bestehenden "
|
||||
"Berechnung."
|
||||
),
|
||||
}
|
||||
return self._with_nlu(response, nlu, include_diagnostics)
|
||||
|
||||
followup_changes = _new_calculation_followup_changes(
|
||||
interpretation,
|
||||
record,
|
||||
message,
|
||||
)
|
||||
if followup_changes is not None:
|
||||
result = modify_calculation(
|
||||
record.calculation_state,
|
||||
followup_changes,
|
||||
build_info=self.build_info,
|
||||
)
|
||||
applied_as_modification = True
|
||||
elif interpretation.intent == "new_calculation":
|
||||
result = calculate_roll(
|
||||
_calculation_request(interpretation),
|
||||
build_info=self.build_info,
|
||||
)
|
||||
else:
|
||||
if record.calculation_state is None:
|
||||
response = {
|
||||
"status": "needs_calculation",
|
||||
"message": (
|
||||
"Es gibt noch keine Berechnung, die geändert werden kann. "
|
||||
"Bitte starte zuerst eine neue Berechnung."
|
||||
),
|
||||
}
|
||||
return self._with_nlu(response, nlu, include_diagnostics)
|
||||
changes = interpretation.changes or {}
|
||||
current_request = record.calculation_state.get("request", {})
|
||||
current_article = current_request.get("article_number")
|
||||
different_article = (
|
||||
"article_number" in changes
|
||||
and changes["article_number"] != current_article
|
||||
) or "article_name_hint" in changes
|
||||
if different_article:
|
||||
new_request = {
|
||||
"intent": "calculate_roll_diameter",
|
||||
**{
|
||||
field: changes[field]
|
||||
for field in ("article_number", "article_name_hint")
|
||||
if field in changes
|
||||
},
|
||||
}
|
||||
result = calculate_roll(
|
||||
new_request,
|
||||
build_info=self.build_info,
|
||||
)
|
||||
else:
|
||||
result = modify_calculation(
|
||||
record.calculation_state,
|
||||
changes,
|
||||
build_info=self.build_info,
|
||||
)
|
||||
applied_as_modification = True
|
||||
|
||||
if clarification is not None and result.get("status") == "invalid_parameter":
|
||||
response = {
|
||||
"status": "needs_clarification",
|
||||
"message": incompatible_clarification_message(clarification.field),
|
||||
"clarification": clarification.to_dict(),
|
||||
"result": result,
|
||||
}
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
|
||||
pending_changes = None
|
||||
if applied_as_modification:
|
||||
if clarification is not None:
|
||||
pending_changes = changes
|
||||
elif nlu is not None and nlu.interpretation.intent == "modify_calculation":
|
||||
pending_changes = nlu.interpretation.changes
|
||||
else:
|
||||
pending_changes = followup_changes
|
||||
new_pending = _pending_clarification(
|
||||
result,
|
||||
requested_changes=pending_changes,
|
||||
)
|
||||
preserve_valid_state = (
|
||||
had_valid_result
|
||||
and (
|
||||
(
|
||||
new_pending is not None
|
||||
and new_pending.reason == "ambiguous_article"
|
||||
)
|
||||
or (
|
||||
applied_as_modification
|
||||
and (
|
||||
(
|
||||
new_pending is not None
|
||||
and new_pending.reason
|
||||
in {
|
||||
"ambiguous_core_preset",
|
||||
"ambiguous_core_type",
|
||||
"ambiguous_article",
|
||||
}
|
||||
)
|
||||
or result.get("status")
|
||||
in {
|
||||
"invalid_parameter",
|
||||
"article_not_found",
|
||||
"article_conflict",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if not preserve_valid_state:
|
||||
state = result.get("state")
|
||||
if isinstance(state, dict):
|
||||
record.calculation_state = state
|
||||
record.last_result = result
|
||||
record.pending_clarification = new_pending
|
||||
self.store.save(conversation_id, record)
|
||||
|
||||
if result.get("status") != "success":
|
||||
response = {
|
||||
"status": result.get("status", "invalid_parameter"),
|
||||
"message": clarification_message(result, new_pending),
|
||||
"result": result,
|
||||
}
|
||||
if new_pending is not None:
|
||||
pending_context = new_pending.to_dict()
|
||||
response["pending_clarification"] = pending_context
|
||||
if new_pending.reason == "ambiguous_article":
|
||||
response.update({
|
||||
"field": pending_context["field"],
|
||||
"reason": pending_context["reason"],
|
||||
"candidates": pending_context["candidates"],
|
||||
})
|
||||
if clarification is not None:
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
return self._with_nlu(response, nlu, include_diagnostics)
|
||||
|
||||
report_id = self.store.add_report(conversation_id, result["request"])
|
||||
pdf_url = f"/api/conversations/reports/{report_id}.pdf"
|
||||
response = {
|
||||
"status": "success",
|
||||
"message": success_message(result, pdf_url),
|
||||
"result": result,
|
||||
"pdf": {"report_id": report_id, "url": pdf_url},
|
||||
}
|
||||
if clarification is not None:
|
||||
return self._with_clarification(
|
||||
response,
|
||||
clarification,
|
||||
include_diagnostics,
|
||||
)
|
||||
return self._with_nlu(response, nlu, include_diagnostics)
|
||||
|
||||
@staticmethod
|
||||
def _with_nlu(
|
||||
response: dict[str, Any],
|
||||
nlu: NLUResponse,
|
||||
include_diagnostics: bool,
|
||||
) -> dict[str, Any]:
|
||||
response["interpretation"] = nlu.interpretation.to_dict()
|
||||
response["nlu"] = {
|
||||
"model": nlu.model,
|
||||
"latency_ms": round(nlu.latency_ms, 1),
|
||||
}
|
||||
if include_diagnostics:
|
||||
response["nlu"]["raw_model_json"] = nlu.raw_model_json
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _with_clarification(
|
||||
response: dict[str, Any],
|
||||
clarification: ClarificationInterpretation,
|
||||
include_diagnostics: bool,
|
||||
) -> dict[str, Any]:
|
||||
response["interpretation"] = clarification.to_dict()
|
||||
response["nlu"] = {
|
||||
"model": "deterministic_clarification_parser",
|
||||
"latency_ms": 0.0,
|
||||
}
|
||||
if include_diagnostics:
|
||||
response["nlu"]["raw_model_json"] = None
|
||||
return response
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
"""Canonical core presets shared by the UI and deterministic services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CorePreset:
|
||||
key: str
|
||||
diameter_mm: float
|
||||
material: str
|
||||
label: str
|
||||
aliases: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def ui_diameter(self) -> str:
|
||||
return f"{self.diameter_mm:g}"
|
||||
|
||||
|
||||
CORE_PRESETS = (
|
||||
CorePreset(
|
||||
key="steel_133",
|
||||
diameter_mm=133.0,
|
||||
material="steel",
|
||||
label="133mm Stahl",
|
||||
aliases=(
|
||||
"133 mm Stahl",
|
||||
"133mm Stahlkern",
|
||||
"133 mm Stahlkern",
|
||||
"133mm steel",
|
||||
"133 mm steel",
|
||||
"133mm steel core",
|
||||
"133 mm steel core",
|
||||
),
|
||||
),
|
||||
CorePreset(
|
||||
key="pvc_150",
|
||||
diameter_mm=150.0,
|
||||
material="pvc",
|
||||
label="150mm PVC",
|
||||
aliases=(
|
||||
"150 mm PVC",
|
||||
"150mm PVC-Kern",
|
||||
"150 mm PVC-Kern",
|
||||
"150mm plastic core",
|
||||
"150 mm plastic core",
|
||||
),
|
||||
),
|
||||
CorePreset(
|
||||
key="pvc_168",
|
||||
diameter_mm=168.0,
|
||||
material="pvc",
|
||||
label="168mm PVC",
|
||||
aliases=(
|
||||
"168 mm PVC",
|
||||
"168mm PVC-Kern",
|
||||
"168 mm PVC-Kern",
|
||||
"168mm plastic core",
|
||||
"168 mm plastic core",
|
||||
),
|
||||
),
|
||||
CorePreset(
|
||||
key="steel_194",
|
||||
diameter_mm=194.0,
|
||||
material="steel",
|
||||
label="194mm Stahl",
|
||||
aliases=(
|
||||
"194 mm Stahl",
|
||||
"194mm Stahlkern",
|
||||
"194 mm Stahlkern",
|
||||
"194mm steel",
|
||||
"194 mm steel",
|
||||
"194mm steel core",
|
||||
"194 mm steel core",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
CORE_FAMILY_ALIASES = {
|
||||
"stahl": "steel",
|
||||
"stahlkern": "steel",
|
||||
"stahl kern": "steel",
|
||||
"steel": "steel",
|
||||
"steel core": "steel",
|
||||
"pvc": "pvc",
|
||||
"pvc kern": "pvc",
|
||||
"pvc core": "pvc",
|
||||
"kunststoff": "pvc",
|
||||
"kunststoffkern": "pvc",
|
||||
"kunststoff kern": "pvc",
|
||||
"plastic": "pvc",
|
||||
"plastic core": "pvc",
|
||||
}
|
||||
|
||||
|
||||
def normalize_core_reference(value: str) -> str:
|
||||
normalized = value.casefold().strip()
|
||||
normalized = re.sub(r"[-_]", " ", normalized)
|
||||
return re.sub(r"\s+", " ", normalized)
|
||||
|
||||
|
||||
def core_family(value: str) -> str | None:
|
||||
return CORE_FAMILY_ALIASES.get(normalize_core_reference(value))
|
||||
|
||||
|
||||
def matching_core_presets(value: str) -> tuple[CorePreset, ...]:
|
||||
"""Return exact preset aliases or all presets in an explicit family alias."""
|
||||
normalized = normalize_core_reference(value)
|
||||
exact = tuple(
|
||||
preset
|
||||
for preset in CORE_PRESETS
|
||||
if normalized
|
||||
in {
|
||||
normalize_core_reference(preset.label),
|
||||
*(normalize_core_reference(alias) for alias in preset.aliases),
|
||||
}
|
||||
)
|
||||
if exact:
|
||||
return exact
|
||||
material = CORE_FAMILY_ALIASES.get(normalized)
|
||||
if material is None:
|
||||
return ()
|
||||
return tuple(preset for preset in CORE_PRESETS if preset.material == material)
|
||||
@@ -1,5 +1,7 @@
|
||||
# 🚀 RollCalc V14 + QoL Update - Deployment Guide
|
||||
|
||||
> Current repository note: direct roll-diameter calculations are generated server-side by `roll_calculation.py`, and PDF reports are rendered by `pdf_report.py`. Deploy both beside `app.py`, install the unchanged `requirements.txt`, and ensure nginx forwards authenticated `POST /api/calculations/roll`, `POST /api/calculations/roll/modify`, and `POST /api/reports/roll-calculation.pdf` requests to Flask. No browser, system PDF/font package, or additional Python dependency is required; the generated report is one A4 page under normal calculator conditions.
|
||||
|
||||
## 📦 Archive Contents
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added deterministic, token-bounded product-family normalization in the article resolver for `Stex`/`Secutex`, `Bfix`/`Bentofix`, and `Sgrid`/`Secugrid`.
|
||||
- Added a minimal OpenWebUI Pipe that maps each OpenWebUI user/chat to the
|
||||
existing RollCalc conversation API, renders only deterministic messages, and
|
||||
exposes validated reports through a configurable browser-public URL.
|
||||
- Added friendly adapter errors, per-chat asynchronous request serialization,
|
||||
server-side Basic Auth without credential-bearing links, deployment/demo
|
||||
documentation, and mocked OpenWebUI Pipe tests without a RollCalc dependency.
|
||||
- Added a direct, dependency-free Ollama `/api/chat` client, defaulting to the configurable `qwen3.5:35B-A3B` model, with temperature `0`, thinking disabled, timeout handling, and JSON-schema-constrained output.
|
||||
- Added strict `new_calculation`, multi-field `modify_calculation`, and `unsupported` NLU validation; calculated and unknown fields are rejected.
|
||||
- Added process-local conversation state, deterministic German clarification/result templates, and temporary input-only PDF references.
|
||||
- Added authenticated conversation creation/message/report routes and a `python -m rollcalc_chat` terminal demo with optional diagnostics.
|
||||
- Added mocked Ollama, schema, conversational workflow, API, and authoritative conversational PDF tests; normal tests do not require Ollama.
|
||||
- Added `roll_calculation.py` as the authoritative headless service for direct diameter ranges, optional roll weight, article/core resolution, and warnings.
|
||||
- Added JSON-serializable calculation request/state/result models, structured clarification/failure statuses, and controlled mutation/recalculation.
|
||||
- Reject caller-supplied result fields in both requests and calculation state, and require clarification for duplicate exact article identifiers.
|
||||
- Added authenticated calculation and modification API boundaries for future tool use; no LLM integration was added.
|
||||
- Routed the active direct-diameter UI and PDF generation through the deterministic service and rejected caller-supplied engineering result fields.
|
||||
- Added archive-safe PDF filenames with date and `HHMMSS` time.
|
||||
- Added an authenticated **Download PDF** action for valid direct roll-diameter results.
|
||||
- Added compact, one-page A4 reports with article data, material inputs, minimum/average/maximum diameter, optional roll weight, active forklift warnings/notes, generation time, and build metadata.
|
||||
- Embedded repeated diagonal `INTERNAL USE ONLY` marks directly in the PDF page content stream. This is an internal-use marking and deterrent, not DRM or a tamper-proof control.
|
||||
- Added strict report-model validation and filesystem-safe archive filenames.
|
||||
- Added PDF model and endpoint tests without adding a new runtime dependency.
|
||||
- Renamed the PDF result section to **Calculation Results** and added the effective roll length as a fourth primary result card.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# OpenWebUI RollCalc Pipe
|
||||
|
||||
`rollcalc_pipe.py` registers **RollCalc Assistant** as a selectable OpenWebUI
|
||||
Pipe model. It does not call an OpenWebUI chat model and contains no engineering
|
||||
logic. Each user message is forwarded to the existing authenticated RollCalc
|
||||
conversation API, and only its deterministic `message` plus a safe report link
|
||||
is rendered.
|
||||
|
||||
## Deployment discovery
|
||||
|
||||
The OpenWebUI runtime was not visible from the coding sandbox: the Docker socket
|
||||
was inaccessible, no native OpenWebUI files/processes were visible, and the
|
||||
usual local ports were not reachable. Run these commands directly on
|
||||
`fertigungski` before enabling the Pipe:
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
|
||||
docker inspect <openwebui-container> --format '{{json .NetworkSettings.Networks}}'
|
||||
docker exec <openwebui-container> python -c \
|
||||
'from importlib.metadata import version; print(version("open-webui"))'
|
||||
```
|
||||
|
||||
For a native installation, use:
|
||||
|
||||
```bash
|
||||
systemctl list-units --type=service | grep -i open-webui
|
||||
python3 -c 'from importlib.metadata import version; print(version("open-webui"))'
|
||||
```
|
||||
|
||||
Also verify the actual route in both network contexts:
|
||||
|
||||
```bash
|
||||
# From the OpenWebUI container/server namespace
|
||||
curl -u "$ROLLCALC_USERNAME:$ROLLCALC_PASSWORD" \
|
||||
"$ROLLCALC_API_BASE_URL/api/health"
|
||||
|
||||
# From a demo user's browser/network (run on a representative workstation)
|
||||
curl -I "$ROLLCALC_PUBLIC_BASE_URL/api/conversations/reports/not-found.pdf"
|
||||
```
|
||||
|
||||
An authenticated `404` for the deliberately invalid report is sufficient for
|
||||
the second network check. A `401` confirms that the public route exists but the
|
||||
browser has not authenticated yet.
|
||||
|
||||
## Install and enable
|
||||
|
||||
1. Open **Admin Panel → Workspace → Functions** in OpenWebUI.
|
||||
2. Create/import a Function using the complete contents of
|
||||
`integrations/openwebui/rollcalc_pipe.py`.
|
||||
3. Save it and enable the Function.
|
||||
4. Open its Valves and configure the values below.
|
||||
5. Start a new chat and select **RollCalc Assistant** as the model.
|
||||
|
||||
The Pipe uses `pydantic` and asynchronous `httpx`, which are OpenWebUI runtime
|
||||
dependencies; RollCalc's Python environment gains no package dependency.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables provide defaults; Function Valves can override them in
|
||||
OpenWebUI:
|
||||
|
||||
| Variable / Valve | Purpose |
|
||||
| --- | --- |
|
||||
| `ROLLCALC_API_BASE_URL` | RollCalc base URL reachable by the OpenWebUI server/container. |
|
||||
| `ROLLCALC_PUBLIC_BASE_URL` | RollCalc base URL reachable by the user's browser. |
|
||||
| `ROLLCALC_USERNAME` | Basic-Auth user used by the Pipe for API calls. |
|
||||
| `ROLLCALC_PASSWORD` | Basic-Auth password used by the Pipe for API calls. |
|
||||
| `ROLLCALC_OPENWEBUI_TIMEOUT_SECONDS` | End-to-end request timeout; default `90`. |
|
||||
| `ROLLCALC_OPENWEBUI_DEBUG` | Safe console diagnostics; default `false`. |
|
||||
|
||||
Do not put credentials into either URL. Store the password in the protected
|
||||
OpenWebUI Valve/environment configuration and do not commit it.
|
||||
|
||||
`localhost` inside a container refers to that container. Typical internal URLs
|
||||
are:
|
||||
|
||||
- `http://rollcalc:5000` when both services share a Docker network;
|
||||
- `http://host.docker.internal:5000` when RollCalc runs on the host and the
|
||||
Linux container has an explicit `host-gateway` mapping;
|
||||
- the server's real internal DNS name when the services run on separate hosts.
|
||||
|
||||
The public URL must be the externally reachable origin/path, for example
|
||||
`https://fertigungski.example/rollcalc`. It must never be an internal-only
|
||||
container hostname.
|
||||
|
||||
Qwen remains configured on the RollCalc service, not in the Pipe. For the
|
||||
validated local model, start RollCalc with:
|
||||
|
||||
```bash
|
||||
export ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B
|
||||
```
|
||||
|
||||
The existing `ROLLCALC_OLLAMA_URL`, timeout, temperature `0`, `think=false`,
|
||||
and structured-output settings remain authoritative in `ollama_nlu.py`.
|
||||
|
||||
## Session and authentication behavior
|
||||
|
||||
The Pipe maps `(OpenWebUI user ID, OpenWebUI chat ID)` to one RollCalc
|
||||
`conversation_id`. It serializes messages within one chat and allows different
|
||||
chats to wait on Qwen independently. The mapping is process-local and is lost
|
||||
when OpenWebUI reloads the Function or restarts. RollCalc's own conversation and
|
||||
report stores are also process-local and are lost when RollCalc restarts.
|
||||
|
||||
If RollCalc reports an expired/unknown conversation, the Pipe removes the
|
||||
mapping and asks the user to repeat a complete initial request. It deliberately
|
||||
does not replay a follow-up into an empty technical state.
|
||||
|
||||
API calls authenticate server-to-server with the configured Basic-Auth account.
|
||||
PDF links never contain those credentials. The existing PDF endpoint therefore
|
||||
prompts the browser for RollCalc Basic Auth on first access. For the demo, open
|
||||
and authenticate to `ROLLCALC_PUBLIC_BASE_URL` once in the same browser. Shared
|
||||
SSO or a short-lived download token is not implemented.
|
||||
|
||||
Because all Pipe API calls use one service account, RollCalc's current access
|
||||
log records that service identity rather than the individual OpenWebUI user.
|
||||
|
||||
## Demo procedure
|
||||
|
||||
For a repository-local validation, use separate terminals:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
ollama serve
|
||||
|
||||
# Terminal 2
|
||||
cd /opt/git-projects/RollCalcPython
|
||||
ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B .venv/bin/python app.py
|
||||
```
|
||||
|
||||
Start/restart the already-discovered OpenWebUI deployment using its actual
|
||||
container or native service name; do not create a second deployment merely for
|
||||
the Pipe. Configure and enable the Function as described above. Then open
|
||||
`ROLLCALC_PUBLIC_BASE_URL` once and complete its Basic-Auth prompt.
|
||||
|
||||
Select **RollCalc Assistant** in OpenWebUI and use one chat for the entire
|
||||
sequence:
|
||||
|
||||
```text
|
||||
Welchen Durchmesser hat Bentofix NSP 4900, Artikelnummer 180205 bei 65 m Länge?
|
||||
150 mm
|
||||
Bitte ändere den Kern auf einen Stahlkern.
|
||||
194 mm
|
||||
Wie schwer ist die berechnete Rolle?
|
||||
Ändere die Länge auf 80 m.
|
||||
```
|
||||
|
||||
Select one of the candidate diameters actually offered by RollCalc if the preset
|
||||
list differs. Verify that each successful recalculation has a new **PDF
|
||||
herunterladen** link, that the weight remains visible after the final length
|
||||
change, and that opening the links produces authenticated PDFs.
|
||||
|
||||
## Known demo limitations
|
||||
|
||||
- There is no streaming token output; the Pipe returns when the local Qwen plus
|
||||
deterministic RollCalc request completes.
|
||||
- Session mappings, RollCalc conversation state, and reports are in memory only.
|
||||
- Multiple OpenWebUI or RollCalc workers do not share state.
|
||||
- Browser and server-side Basic-Auth sessions are separate.
|
||||
- RollCalc audit entries identify the configured Pipe service account, not the
|
||||
originating OpenWebUI user.
|
||||
- OpenWebUI direct API calls without a stable user/chat identifier are rejected
|
||||
instead of sharing or guessing a session.
|
||||
- OpenWebUI version, deployment topology, and the complete browser flow still
|
||||
need verification in the actual `fertigungski` host namespace.
|
||||
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
title: RollCalc Assistant
|
||||
author: RollCalc
|
||||
version: 0.1.0
|
||||
description: Deterministic RollCalc conversation API adapter
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
|
||||
def _environment_flag(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class Pipe:
|
||||
"""Expose RollCalc as an OpenWebUI model without an LLM tool loop."""
|
||||
|
||||
class Valves(BaseModel):
|
||||
ROLLCALC_API_BASE_URL: str = Field(
|
||||
default=os.getenv("ROLLCALC_API_BASE_URL", ""),
|
||||
description=(
|
||||
"RollCalc URL reachable from the OpenWebUI server/container, "
|
||||
"for example http://host.docker.internal:5000"
|
||||
),
|
||||
)
|
||||
ROLLCALC_PUBLIC_BASE_URL: str = Field(
|
||||
default=os.getenv("ROLLCALC_PUBLIC_BASE_URL", ""),
|
||||
description=(
|
||||
"RollCalc URL reachable from the user's browser; used only "
|
||||
"for authenticated PDF links"
|
||||
),
|
||||
)
|
||||
ROLLCALC_USERNAME: str = Field(
|
||||
default=os.getenv("ROLLCALC_USERNAME", ""),
|
||||
description="HTTP Basic Auth username used server-side",
|
||||
)
|
||||
ROLLCALC_PASSWORD: SecretStr = Field(
|
||||
default=SecretStr(os.getenv("ROLLCALC_PASSWORD", "")),
|
||||
description="HTTP Basic Auth password used server-side",
|
||||
)
|
||||
ROLLCALC_TIMEOUT_SECONDS: float = Field(
|
||||
default=float(os.getenv("ROLLCALC_OPENWEBUI_TIMEOUT_SECONDS", "90")),
|
||||
ge=1,
|
||||
le=600,
|
||||
description="End-to-end timeout for one RollCalc request",
|
||||
)
|
||||
ROLLCALC_DEBUG: bool = Field(
|
||||
default=_environment_flag("ROLLCALC_OPENWEBUI_DEBUG"),
|
||||
description="Log safe adapter diagnostics to the OpenWebUI console",
|
||||
)
|
||||
|
||||
type = "pipe"
|
||||
name = "RollCalc Assistant"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_factory: Callable[..., Any] | None = None,
|
||||
) -> None:
|
||||
self.valves = self.Valves()
|
||||
self._client_factory = client_factory or httpx.AsyncClient
|
||||
self._conversations: dict[tuple[str, str], str] = {}
|
||||
self._chat_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
self._lock_guard = asyncio.Lock()
|
||||
|
||||
async def pipe(
|
||||
self,
|
||||
body: dict[str, Any],
|
||||
__user__: Any = None,
|
||||
__metadata__: dict[str, Any] | None = None,
|
||||
__chat_id__: str | None = None,
|
||||
__task__: str | None = None,
|
||||
) -> str:
|
||||
"""Forward one browser message to one authoritative RollCalc session."""
|
||||
if __task__:
|
||||
return self._task_response(__task__)
|
||||
|
||||
configuration_error = self._configuration_error()
|
||||
if configuration_error:
|
||||
return configuration_error
|
||||
|
||||
message = self._user_message(body, __metadata__)
|
||||
if not message:
|
||||
return "Bitte gib eine RollCalc-Anfrage als Text ein."
|
||||
|
||||
session_key = self._session_key(
|
||||
body,
|
||||
__user__,
|
||||
__metadata__,
|
||||
__chat_id__,
|
||||
)
|
||||
if session_key is None:
|
||||
return (
|
||||
"Die RollCalc-Sitzung konnte diesem Chat nicht sicher zugeordnet "
|
||||
"werden. Bitte verwende einen gespeicherten OpenWebUI-Chat."
|
||||
)
|
||||
|
||||
chat_lock = await self._chat_lock(session_key)
|
||||
async with chat_lock:
|
||||
try:
|
||||
return await self._send_message(session_key, message)
|
||||
except httpx.TimeoutException:
|
||||
return (
|
||||
"RollCalc benötigt derzeit zu lange für eine Antwort. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
except httpx.RequestError:
|
||||
return (
|
||||
"RollCalc ist derzeit nicht erreichbar. "
|
||||
"Bitte versuche es später erneut."
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return (
|
||||
"RollCalc hat eine unerwartete Antwort geliefert. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
except Exception as error:
|
||||
self._debug(f"unexpected adapter error: {error.__class__.__name__}")
|
||||
return (
|
||||
"Die RollCalc-Anfrage konnte nicht verarbeitet werden. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
|
||||
async def _send_message(
|
||||
self,
|
||||
session_key: tuple[str, str],
|
||||
message: str,
|
||||
) -> str:
|
||||
api_base = self.valves.ROLLCALC_API_BASE_URL.rstrip("/")
|
||||
timeout = self.valves.ROLLCALC_TIMEOUT_SECONDS
|
||||
auth = httpx.BasicAuth(
|
||||
self.valves.ROLLCALC_USERNAME,
|
||||
self._password(),
|
||||
)
|
||||
async with self._client_factory(timeout=timeout, auth=auth) as client:
|
||||
conversation_id = self._conversations.get(session_key)
|
||||
if conversation_id is None:
|
||||
response = await client.post(f"{api_base}/api/conversations")
|
||||
error = self._http_error(response)
|
||||
if error:
|
||||
return error
|
||||
payload = self._json_object(response)
|
||||
conversation_id = payload.get("conversation_id")
|
||||
if not isinstance(conversation_id, str) or not conversation_id:
|
||||
raise ValueError("missing conversation_id")
|
||||
self._conversations[session_key] = conversation_id
|
||||
|
||||
response = await client.post(
|
||||
f"{api_base}/api/conversations/{conversation_id}/messages",
|
||||
json={"message": message},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
self._conversations.pop(session_key, None)
|
||||
return (
|
||||
"Die RollCalc-Sitzung ist abgelaufen. Bitte starte die "
|
||||
"Berechnung mit deiner vollständigen Anfrage erneut."
|
||||
)
|
||||
error = self._http_error(response)
|
||||
if error:
|
||||
return error
|
||||
payload = self._json_object(response)
|
||||
response_message = payload.get("message")
|
||||
if not isinstance(response_message, str) or not response_message.strip():
|
||||
raise ValueError("missing user-facing message")
|
||||
return self._render_response(response_message, payload.get("pdf"))
|
||||
|
||||
async def _chat_lock(self, key: tuple[str, str]) -> asyncio.Lock:
|
||||
async with self._lock_guard:
|
||||
lock = self._chat_locks.get(key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[key] = lock
|
||||
return lock
|
||||
|
||||
def _configuration_error(self) -> str | None:
|
||||
api_url = self.valves.ROLLCALC_API_BASE_URL.strip()
|
||||
if not self._valid_base_url(api_url):
|
||||
return (
|
||||
"RollCalc ist in OpenWebUI noch nicht vollständig konfiguriert. "
|
||||
"Bitte hinterlege eine gültige interne RollCalc-URL."
|
||||
)
|
||||
if not self.valves.ROLLCALC_USERNAME.strip() or not self._password():
|
||||
return (
|
||||
"Die RollCalc-Authentifizierung ist in OpenWebUI noch nicht "
|
||||
"vollständig konfiguriert."
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _valid_base_url(value: str) -> bool:
|
||||
parsed = urlsplit(value)
|
||||
return (
|
||||
parsed.scheme in {"http", "https"}
|
||||
and bool(parsed.netloc)
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
)
|
||||
|
||||
def _password(self) -> str:
|
||||
password = self.valves.ROLLCALC_PASSWORD
|
||||
getter = getattr(password, "get_secret_value", None)
|
||||
return getter() if callable(getter) else str(password)
|
||||
|
||||
@staticmethod
|
||||
def _json_object(response: Any) -> dict[str, Any]:
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("JSON object required")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _http_error(response: Any) -> str | None:
|
||||
if 200 <= response.status_code < 300:
|
||||
return None
|
||||
if response.status_code == 400:
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = None
|
||||
message = payload.get("message") if isinstance(payload, dict) else None
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message
|
||||
return "Die Anfrage enthält ungültige Eingaben. Bitte prüfe sie."
|
||||
if response.status_code in {401, 403}:
|
||||
return (
|
||||
"Die RollCalc-Authentifizierung ist nicht korrekt konfiguriert. "
|
||||
"Bitte informiere die Administration."
|
||||
)
|
||||
if response.status_code == 503:
|
||||
return (
|
||||
"Die lokale Spracherkennung ist derzeit nicht erreichbar. "
|
||||
"Bitte versuche es später erneut."
|
||||
)
|
||||
if response.status_code == 504:
|
||||
return (
|
||||
"Die lokale Spracherkennung benötigt derzeit zu lange. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
return (
|
||||
"RollCalc konnte die Anfrage nicht verarbeiten. "
|
||||
"Bitte versuche es erneut."
|
||||
)
|
||||
|
||||
def _render_response(self, message: str, pdf: Any) -> str:
|
||||
lines = message.strip().splitlines()
|
||||
lines = [line for line in lines if not line.strip().startswith("PDF:")]
|
||||
if isinstance(pdf, dict) and isinstance(pdf.get("url"), str):
|
||||
report_path = pdf["url"]
|
||||
if self._valid_report_path(report_path):
|
||||
public_base = self.valves.ROLLCALC_PUBLIC_BASE_URL.strip()
|
||||
if self._valid_base_url(public_base):
|
||||
report_url = urljoin(
|
||||
f"{public_base.rstrip('/')}/",
|
||||
report_path.lstrip("/"),
|
||||
)
|
||||
lines.extend(("", f"[PDF herunterladen]({report_url})"))
|
||||
else:
|
||||
lines.extend((
|
||||
"",
|
||||
"PDF erstellt; öffentlicher Download-Link nicht "
|
||||
"konfiguriert.",
|
||||
))
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
@staticmethod
|
||||
def _valid_report_path(value: str) -> bool:
|
||||
return bool(
|
||||
re.fullmatch(
|
||||
r"/api/conversations/reports/[A-Za-z0-9_-]+\.pdf",
|
||||
value,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _user_message(
|
||||
body: dict[str, Any],
|
||||
metadata: dict[str, Any] | None,
|
||||
) -> str | None:
|
||||
if isinstance(metadata, dict):
|
||||
prompt = metadata.get("user_prompt")
|
||||
if isinstance(prompt, str) and prompt.strip():
|
||||
return prompt.strip()
|
||||
messages = body.get("messages", []) if isinstance(body, dict) else []
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for item in reversed(messages):
|
||||
if not isinstance(item, dict) or item.get("role") != "user":
|
||||
continue
|
||||
content = item.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return content.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _session_key(
|
||||
body: dict[str, Any],
|
||||
user: Any,
|
||||
metadata: dict[str, Any] | None,
|
||||
injected_chat_id: str | None,
|
||||
) -> tuple[str, str] | None:
|
||||
user_id = None
|
||||
if isinstance(user, dict):
|
||||
user_id = user.get("id") or user.get("email")
|
||||
else:
|
||||
user_id = getattr(user, "id", None) or getattr(user, "email", None)
|
||||
if not user_id and isinstance(metadata, dict):
|
||||
user_id = metadata.get("user_id")
|
||||
|
||||
chat_id = injected_chat_id
|
||||
if isinstance(metadata, dict):
|
||||
chat_id = (
|
||||
chat_id
|
||||
or metadata.get("chat_id")
|
||||
or metadata.get("session_id")
|
||||
)
|
||||
if isinstance(body, dict):
|
||||
chat_id = chat_id or body.get("chat_id") or body.get("session_id")
|
||||
if not user_id or not chat_id:
|
||||
return None
|
||||
return str(user_id), str(chat_id)
|
||||
|
||||
@staticmethod
|
||||
def _task_response(task: str) -> str:
|
||||
normalized = task.casefold()
|
||||
if "title" in normalized:
|
||||
return "RollCalc"
|
||||
if "emoji" in normalized:
|
||||
return "🧮"
|
||||
if "tag" in normalized or "follow" in normalized:
|
||||
return "[]"
|
||||
return ""
|
||||
|
||||
def _debug(self, message: str) -> None:
|
||||
if self.valves.ROLLCALC_DEBUG:
|
||||
print(f"[RollCalc OpenWebUI] {message}")
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
"""Strict Ollama-backed natural-language interpretation for RollCalc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434"
|
||||
DEFAULT_OLLAMA_MODEL = "qwen3.5:35B-A3B"
|
||||
DEFAULT_TIMEOUT_SECONDS = 45.0
|
||||
DEFAULT_MAX_RESPONSE_BYTES = 1_000_000
|
||||
MAX_HTTP_ERROR_BODY_BYTES = 4_096
|
||||
MAX_HTTP_ERROR_DIAGNOSTIC_CHARS = 1_000
|
||||
|
||||
NEW_CALCULATION_FIELDS = {
|
||||
"intent",
|
||||
"article_number",
|
||||
"article_name_hint",
|
||||
"roll_length_m",
|
||||
"width_m",
|
||||
"core_type",
|
||||
"core_diameter_mm",
|
||||
"include_roll_weight",
|
||||
}
|
||||
CHANGE_FIELDS = {
|
||||
"article_number",
|
||||
"article_name_hint",
|
||||
"roll_length_m",
|
||||
"width_m",
|
||||
"core_type",
|
||||
"core_diameter_mm",
|
||||
"include_roll_weight",
|
||||
}
|
||||
|
||||
|
||||
NLU_JSON_SCHEMA: dict[str, Any] = {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"intent": {"const": "new_calculation"},
|
||||
"article_number": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{"type": "string", "minLength": 1},
|
||||
]
|
||||
},
|
||||
"article_name_hint": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{"type": "string", "minLength": 1},
|
||||
]
|
||||
},
|
||||
"roll_length_m": {
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"width_m": {
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"core_type": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{"type": "string", "minLength": 1},
|
||||
]
|
||||
},
|
||||
"core_diameter_mm": {
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"include_roll_weight": {"type": "boolean"},
|
||||
},
|
||||
"required": sorted(NEW_CALCULATION_FIELDS),
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"intent": {"const": "modify_calculation"},
|
||||
"changes": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"article_number": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
},
|
||||
"article_name_hint": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
},
|
||||
"roll_length_m": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"width_m": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"core_type": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
},
|
||||
"core_diameter_mm": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
},
|
||||
"include_roll_weight": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["intent", "changes"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"intent": {"const": "unsupported"}},
|
||||
"required": ["intent"],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class NLUError(RuntimeError):
|
||||
"""Base error for controlled NLU failures."""
|
||||
|
||||
|
||||
class NLUValidationError(NLUError):
|
||||
"""Raised when model output is not valid under the narrow NLU schema."""
|
||||
|
||||
|
||||
class OllamaUnavailableError(NLUError):
|
||||
"""Raised when the configured local Ollama service cannot be reached."""
|
||||
|
||||
|
||||
class OllamaTimeoutError(NLUError):
|
||||
"""Raised when Ollama does not answer within the configured timeout."""
|
||||
|
||||
|
||||
class OllamaResponseError(NLUError):
|
||||
"""Raised when Ollama returns an unsuccessful or malformed response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OllamaConfig:
|
||||
base_url: str = DEFAULT_OLLAMA_URL
|
||||
model: str = DEFAULT_OLLAMA_MODEL
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
||||
temperature: float = 0.0
|
||||
think: bool = False
|
||||
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "OllamaConfig":
|
||||
return cls(
|
||||
base_url=os.getenv("ROLLCALC_OLLAMA_URL", DEFAULT_OLLAMA_URL),
|
||||
model=os.getenv("ROLLCALC_OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL),
|
||||
timeout_seconds=float(
|
||||
os.getenv(
|
||||
"ROLLCALC_OLLAMA_TIMEOUT_SECONDS",
|
||||
str(DEFAULT_TIMEOUT_SECONDS),
|
||||
)
|
||||
),
|
||||
temperature=float(os.getenv("ROLLCALC_OLLAMA_TEMPERATURE", "0")),
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.base_url.strip():
|
||||
raise ValueError("Ollama base URL is required")
|
||||
if not self.model.strip():
|
||||
raise ValueError("Ollama model is required")
|
||||
if not math.isfinite(self.timeout_seconds) or self.timeout_seconds <= 0:
|
||||
raise ValueError("Ollama timeout must be greater than zero")
|
||||
if not math.isfinite(self.temperature) or self.temperature < 0:
|
||||
raise ValueError("Ollama temperature must be zero or greater")
|
||||
if self.max_response_bytes <= 0:
|
||||
raise ValueError("Ollama response limit must be greater than zero")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NLUInterpretation:
|
||||
intent: str
|
||||
article_number: str | None = None
|
||||
article_name_hint: str | None = None
|
||||
roll_length_m: float | None = None
|
||||
width_m: float | None = None
|
||||
core_type: str | None = None
|
||||
core_diameter_mm: float | None = None
|
||||
include_roll_weight: bool = False
|
||||
changes: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
if self.intent == "modify_calculation":
|
||||
return {"intent": self.intent, "changes": dict(self.changes or {})}
|
||||
if self.intent == "unsupported":
|
||||
return {"intent": self.intent}
|
||||
result = asdict(self)
|
||||
result.pop("changes")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NLUResponse:
|
||||
interpretation: NLUInterpretation
|
||||
raw_model_json: str
|
||||
latency_ms: float
|
||||
model: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClarificationInterpretation:
|
||||
status: str
|
||||
field: str
|
||||
value: Any = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = {"status": self.status, "field": self.field}
|
||||
if self.status == "parsed":
|
||||
result["value"] = self.value
|
||||
return result
|
||||
|
||||
|
||||
_CLARIFICATION_UNITS = {
|
||||
"roll_length_m": {"", "m", "meter", "metern"},
|
||||
"width_m": {"", "m", "meter", "metern"},
|
||||
"thickness_mm": {"", "mm", "millimeter", "millimetern"},
|
||||
"core_diameter_mm": {"", "mm", "millimeter", "millimetern"},
|
||||
"area_weight_g_m2": {"", "g/m2", "gsm"},
|
||||
}
|
||||
_CLARIFICATION_TEXT_FIELDS = {"article_number", "article_name_hint", "core_type"}
|
||||
_CLARIFICATION_NUMBER = re.compile(
|
||||
r"^\s*(?P<number>\d+(?:[.,]\d+)?)\s*"
|
||||
r"(?P<unit>[A-Za-zÀ-ÖØ-öø-ÿ²/^0-9]+)?\s*[.!]?\s*$"
|
||||
)
|
||||
|
||||
|
||||
def parse_clarification_reply(
|
||||
text: str,
|
||||
expected_field: str,
|
||||
) -> ClarificationInterpretation:
|
||||
"""Parse one reply only as the application-selected clarification field."""
|
||||
supported_fields = set(_CLARIFICATION_UNITS) | _CLARIFICATION_TEXT_FIELDS
|
||||
if expected_field not in supported_fields:
|
||||
raise NLUValidationError("unsupported clarification field")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
|
||||
if expected_field in _CLARIFICATION_TEXT_FIELDS:
|
||||
try:
|
||||
value = _required_text(text, expected_field)
|
||||
except NLUValidationError:
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
return ClarificationInterpretation("parsed", expected_field, value)
|
||||
|
||||
match = _CLARIFICATION_NUMBER.fullmatch(text)
|
||||
if match is None:
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
unit = (match.group("unit") or "").casefold()
|
||||
unit = unit.replace("²", "2").replace("^", "")
|
||||
if unit not in _CLARIFICATION_UNITS[expected_field]:
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
value = float(match.group("number").replace(",", "."))
|
||||
try:
|
||||
value = _positive_number(value, expected_field, optional=False)
|
||||
except NLUValidationError:
|
||||
return ClarificationInterpretation("incompatible", expected_field)
|
||||
return ClarificationInterpretation("parsed", expected_field, value)
|
||||
|
||||
|
||||
def _optional_text(value: Any, field: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise NLUValidationError(f"{field} must be text or null")
|
||||
value = " ".join(value.split()).strip()
|
||||
if not value:
|
||||
return None
|
||||
if len(value) > 240:
|
||||
raise NLUValidationError(f"{field} is too long")
|
||||
return value
|
||||
|
||||
|
||||
def _required_text(value: Any, field: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise NLUValidationError(f"{field} must be text")
|
||||
value = " ".join(value.split()).strip()
|
||||
if not value:
|
||||
raise NLUValidationError(f"{field} must not be empty")
|
||||
if len(value) > 240:
|
||||
raise NLUValidationError(f"{field} is too long")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_number(value: Any, field: str, *, optional: bool) -> float | None:
|
||||
if value is None and optional:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
suffix = " or null" if optional else ""
|
||||
raise NLUValidationError(f"{field} must be a number{suffix}")
|
||||
value = float(value)
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise NLUValidationError(f"{field} must be greater than zero")
|
||||
return value
|
||||
|
||||
|
||||
def validate_nlu_payload(payload: Any) -> NLUInterpretation:
|
||||
"""Validate untrusted model output without coercing or inferring values."""
|
||||
if not isinstance(payload, dict):
|
||||
raise NLUValidationError("model output must be a JSON object")
|
||||
intent = payload.get("intent")
|
||||
if intent not in {"new_calculation", "modify_calculation", "unsupported"}:
|
||||
raise NLUValidationError("unsupported or missing NLU intent")
|
||||
|
||||
if intent == "unsupported":
|
||||
unknown = sorted(set(payload) - {"intent"})
|
||||
if unknown:
|
||||
raise NLUValidationError(
|
||||
"unknown unsupported-intent fields: " + ", ".join(unknown)
|
||||
)
|
||||
return NLUInterpretation(intent="unsupported")
|
||||
|
||||
if intent == "new_calculation":
|
||||
unknown = sorted(set(payload) - NEW_CALCULATION_FIELDS)
|
||||
missing = sorted(NEW_CALCULATION_FIELDS - set(payload))
|
||||
if unknown:
|
||||
raise NLUValidationError(
|
||||
"unknown new-calculation fields: " + ", ".join(unknown)
|
||||
)
|
||||
if missing:
|
||||
raise NLUValidationError(
|
||||
"missing new-calculation fields: " + ", ".join(missing)
|
||||
)
|
||||
if not isinstance(payload["include_roll_weight"], bool):
|
||||
raise NLUValidationError("include_roll_weight must be boolean")
|
||||
article_number = _optional_text(payload["article_number"], "article_number")
|
||||
return NLUInterpretation(
|
||||
intent=intent,
|
||||
article_number=article_number,
|
||||
article_name_hint=_optional_text(
|
||||
payload["article_name_hint"], "article_name_hint"
|
||||
),
|
||||
roll_length_m=_positive_number(
|
||||
payload["roll_length_m"], "roll_length_m", optional=True
|
||||
),
|
||||
width_m=_positive_number(payload["width_m"], "width_m", optional=True),
|
||||
core_type=_optional_text(payload["core_type"], "core_type"),
|
||||
core_diameter_mm=_positive_number(
|
||||
payload["core_diameter_mm"], "core_diameter_mm", optional=True
|
||||
),
|
||||
include_roll_weight=payload["include_roll_weight"],
|
||||
)
|
||||
|
||||
unknown = sorted(set(payload) - {"intent", "changes"})
|
||||
if unknown:
|
||||
raise NLUValidationError("unknown modification fields: " + ", ".join(unknown))
|
||||
changes = payload.get("changes")
|
||||
if not isinstance(changes, dict) or not changes:
|
||||
raise NLUValidationError("changes must be a non-empty JSON object")
|
||||
unknown_changes = sorted(set(changes) - CHANGE_FIELDS)
|
||||
if unknown_changes:
|
||||
raise NLUValidationError("unknown change fields: " + ", ".join(unknown_changes))
|
||||
|
||||
validated_changes: dict[str, Any] = {}
|
||||
for field, value in changes.items():
|
||||
if field in {"roll_length_m", "width_m", "core_diameter_mm"}:
|
||||
validated_changes[field] = _positive_number(value, field, optional=False)
|
||||
elif field == "include_roll_weight":
|
||||
if not isinstance(value, bool):
|
||||
raise NLUValidationError("include_roll_weight must be boolean")
|
||||
validated_changes[field] = value
|
||||
else:
|
||||
validated_changes[field] = _required_text(value, field)
|
||||
return NLUInterpretation(
|
||||
intent="modify_calculation",
|
||||
changes=validated_changes,
|
||||
)
|
||||
|
||||
|
||||
def parse_nlu_json(raw_model_json: str) -> NLUInterpretation:
|
||||
if not isinstance(raw_model_json, str) or not raw_model_json.strip():
|
||||
raise NLUValidationError("model output is empty")
|
||||
try:
|
||||
payload = json.loads(raw_model_json)
|
||||
except json.JSONDecodeError as error:
|
||||
raise NLUValidationError("model output is not valid JSON") from error
|
||||
return validate_nlu_payload(payload)
|
||||
|
||||
|
||||
def _http_error_diagnostic(error: HTTPError) -> str:
|
||||
try:
|
||||
raw_body = error.read(MAX_HTTP_ERROR_BODY_BYTES + 1)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not raw_body:
|
||||
return ""
|
||||
if isinstance(raw_body, bytes):
|
||||
body = raw_body.decode("utf-8", errors="replace")
|
||||
else:
|
||||
body = str(raw_body)
|
||||
body = " ".join(body.split())
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
diagnostic = body
|
||||
try:
|
||||
parsed_body = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
else:
|
||||
if isinstance(parsed_body, dict):
|
||||
ollama_error = parsed_body.get("error")
|
||||
if isinstance(ollama_error, str) and ollama_error.strip():
|
||||
diagnostic = " ".join(ollama_error.split())
|
||||
|
||||
if len(diagnostic) > MAX_HTTP_ERROR_DIAGNOSTIC_CHARS:
|
||||
diagnostic = diagnostic[: MAX_HTTP_ERROR_DIAGNOSTIC_CHARS - 3] + "..."
|
||||
return diagnostic
|
||||
|
||||
|
||||
def _system_prompt(
|
||||
*,
|
||||
has_state: bool,
|
||||
expected_fields: tuple[str, ...],
|
||||
current_state: dict[str, Any] | None,
|
||||
) -> str:
|
||||
context = {
|
||||
"has_calculation_state": has_state,
|
||||
"expected_clarification_fields": list(expected_fields),
|
||||
"current_calculation": current_state,
|
||||
}
|
||||
return (
|
||||
"You are the strictly constrained NLU adapter for RollCalc. "
|
||||
"Interpret the user's German or English text; never calculate, estimate, "
|
||||
"or invent engineering values, product data, warnings, defaults, or core "
|
||||
"mappings. Extract a user-provided product phrase only as article_name_hint; "
|
||||
"never invent, rank, or select article candidates. Return only JSON matching "
|
||||
"the supplied schema. Preserve article "
|
||||
"numbers as strings. Convert an explicitly written German decimal comma to "
|
||||
"a JSON number. For every optional textual field the user did not provide, "
|
||||
"return JSON null; never return an empty or whitespace-only string. Set "
|
||||
"include_roll_weight=true only when the user explicitly asks for roll weight. "
|
||||
"Use modify_calculation for follow-ups that change existing structured state; "
|
||||
"put every explicitly requested changed input into changes. "
|
||||
"When current_calculation is present, references such as die Rolle, dieselbe "
|
||||
"Rolle, sie, bei 80 m, mit 5 m Breite, wie schwer ist sie, or nimm einen "
|
||||
"Stahlkern refer to that calculation and must not discard its other inputs. "
|
||||
"Use new_calculation only when the user explicitly starts a new calculation "
|
||||
"or identifies a different article. Do not copy unchanged context values into "
|
||||
"the output. "
|
||||
"If expected clarification fields are listed, interpret a short answer only "
|
||||
"against those fields. Use unsupported for every other task. "
|
||||
"Never emit diameter results, weight results, warnings, explanations, or "
|
||||
"markdown. Technical context: "
|
||||
+ json.dumps(context, ensure_ascii=False, separators=(",", ":"))
|
||||
+ ". Required JSON schema: "
|
||||
+ json.dumps(NLU_JSON_SCHEMA, ensure_ascii=False, separators=(",", ":"))
|
||||
)
|
||||
|
||||
|
||||
class OllamaNLUClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: OllamaConfig | None = None,
|
||||
*,
|
||||
opener: Callable[..., Any] = urlopen,
|
||||
) -> None:
|
||||
self.config = config or OllamaConfig.from_env()
|
||||
self._opener = opener
|
||||
|
||||
def interpret(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
has_state: bool = False,
|
||||
expected_fields: tuple[str, ...] = (),
|
||||
current_state: dict[str, Any] | None = None,
|
||||
) -> NLUResponse:
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise NLUValidationError("message must be non-empty text")
|
||||
if len(text) > 2_000:
|
||||
raise NLUValidationError("message is too long")
|
||||
|
||||
payload = {
|
||||
"model": self.config.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": _system_prompt(
|
||||
has_state=has_state,
|
||||
expected_fields=expected_fields,
|
||||
current_state=current_state,
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": text.strip()},
|
||||
],
|
||||
"stream": False,
|
||||
"think": self.config.think,
|
||||
"format": NLU_JSON_SCHEMA,
|
||||
"options": {
|
||||
"temperature": self.config.temperature,
|
||||
"num_predict": 300,
|
||||
},
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = Request(
|
||||
self.config.base_url.rstrip("/") + "/api/chat",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
with self._opener(request, timeout=self.config.timeout_seconds) as response:
|
||||
raw_response = response.read(self.config.max_response_bytes + 1)
|
||||
except (socket.timeout, TimeoutError) as error:
|
||||
raise OllamaTimeoutError("Ollama request timed out") from error
|
||||
except HTTPError as error:
|
||||
diagnostic = _http_error_diagnostic(error)
|
||||
message = f"Ollama returned HTTP {error.code}"
|
||||
if diagnostic:
|
||||
message += f": {diagnostic}"
|
||||
raise OllamaResponseError(
|
||||
message,
|
||||
status_code=error.code,
|
||||
) from error
|
||||
except URLError as error:
|
||||
if isinstance(error.reason, (socket.timeout, TimeoutError)):
|
||||
raise OllamaTimeoutError("Ollama request timed out") from error
|
||||
raise OllamaUnavailableError("Ollama service is unavailable") from error
|
||||
except OSError as error:
|
||||
raise OllamaUnavailableError("Ollama service is unavailable") from error
|
||||
latency_ms = (time.monotonic() - started) * 1_000
|
||||
|
||||
if len(raw_response) > self.config.max_response_bytes:
|
||||
raise OllamaResponseError("Ollama response is too large")
|
||||
try:
|
||||
response_payload = json.loads(raw_response.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise OllamaResponseError("Ollama returned invalid JSON") from error
|
||||
message = (
|
||||
response_payload.get("message")
|
||||
if isinstance(response_payload, dict)
|
||||
else None
|
||||
)
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if not isinstance(content, str):
|
||||
raise OllamaResponseError("Ollama response has no message content")
|
||||
|
||||
interpretation = parse_nlu_json(content)
|
||||
return NLUResponse(
|
||||
interpretation=interpretation,
|
||||
raw_model_json=content,
|
||||
latency_ms=latency_ms,
|
||||
model=self.config.model,
|
||||
)
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
"""Validated, dependency-free PDF rendering for roll calculation reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
A4_WIDTH = 595.28
|
||||
A4_HEIGHT = 841.89
|
||||
|
||||
|
||||
class ReportValidationError(ValueError):
|
||||
"""Raised when report input is incomplete or unsafe to render."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiameterValue:
|
||||
value: float
|
||||
display: str
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, required: bool = True, limit: int = 240) -> str:
|
||||
if value is None:
|
||||
value = ""
|
||||
if not isinstance(value, str):
|
||||
raise ReportValidationError(f"{field} must be text")
|
||||
value = " ".join(value.split()).strip()
|
||||
if required and not value:
|
||||
raise ReportValidationError(f"{field} is required")
|
||||
if len(value) > limit:
|
||||
raise ReportValidationError(f"{field} is too long")
|
||||
return value
|
||||
|
||||
|
||||
def _positive_number(value: Any, field: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ReportValidationError(f"{field} must be a number")
|
||||
value = float(value)
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ReportValidationError(f"{field} must be greater than zero")
|
||||
return value
|
||||
|
||||
|
||||
def _measurement_display(
|
||||
value: float, display: str, unit: str, field: str, *, decimals: int = 1
|
||||
) -> str:
|
||||
match = re.fullmatch(
|
||||
rf"([0-9]+\.[0-9]{{{decimals}}}) {re.escape(unit)}", display
|
||||
)
|
||||
tolerance = (0.5 * (10 ** -decimals)) + 0.000001
|
||||
if not match or abs(float(match.group(1)) - value) > tolerance:
|
||||
raise ReportValidationError(f"{field} does not match its numeric value")
|
||||
return display
|
||||
|
||||
|
||||
def validate_report_payload(payload: Any) -> dict[str, Any]:
|
||||
"""Validate the internal PDF layout model produced from a calculation result."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ReportValidationError("JSON object required")
|
||||
if payload.get("report_type") != "roll_diameter":
|
||||
raise ReportValidationError("unsupported or incomplete calculation result")
|
||||
|
||||
raw_diameters = payload.get("diameters")
|
||||
if not isinstance(raw_diameters, dict):
|
||||
raise ReportValidationError("diameters are required")
|
||||
|
||||
diameters: dict[str, DiameterValue] = {}
|
||||
for key in ("minimum", "average", "maximum"):
|
||||
raw = raw_diameters.get(key)
|
||||
if not isinstance(raw, dict):
|
||||
raise ReportValidationError(f"diameters.{key} is required")
|
||||
value = _positive_number(raw.get("value"), f"diameters.{key}.value")
|
||||
display_field = f"diameters.{key}.display"
|
||||
display = _text(raw.get("display"), display_field, limit=40)
|
||||
display = _measurement_display(value, display, "mm", display_field)
|
||||
diameters[key] = DiameterValue(value=value, display=display)
|
||||
|
||||
if not (
|
||||
diameters["minimum"].value
|
||||
<= diameters["average"].value
|
||||
<= diameters["maximum"].value
|
||||
):
|
||||
raise ReportValidationError("diameter values must be ordered minimum to maximum")
|
||||
|
||||
raw_length = payload.get("roll_length")
|
||||
if not isinstance(raw_length, dict):
|
||||
raise ReportValidationError("roll_length is required")
|
||||
length_value = _positive_number(raw_length.get("value"), "roll_length.value")
|
||||
length_display = _text(
|
||||
raw_length.get("display"), "roll_length.display", limit=40
|
||||
)
|
||||
roll_length = {
|
||||
"value": length_value,
|
||||
"display": _measurement_display(
|
||||
length_value,
|
||||
length_display,
|
||||
"m",
|
||||
"roll_length.display",
|
||||
decimals=2,
|
||||
),
|
||||
}
|
||||
|
||||
raw_inputs = payload.get("inputs")
|
||||
if not isinstance(raw_inputs, list) or not 3 <= len(raw_inputs) <= 16:
|
||||
raise ReportValidationError("between 3 and 16 calculation inputs are required")
|
||||
inputs = []
|
||||
for index, item in enumerate(raw_inputs):
|
||||
if not isinstance(item, dict):
|
||||
raise ReportValidationError(f"inputs[{index}] must be an object")
|
||||
inputs.append(
|
||||
{
|
||||
"label": _text(item.get("label"), f"inputs[{index}].label", limit=60),
|
||||
"value": _text(item.get("value"), f"inputs[{index}].value", limit=100),
|
||||
}
|
||||
)
|
||||
|
||||
article = None
|
||||
raw_article = payload.get("article")
|
||||
if raw_article is not None:
|
||||
if not isinstance(raw_article, dict):
|
||||
raise ReportValidationError("article must be an object")
|
||||
number = _text(raw_article.get("number"), "article.number", required=False, limit=60)
|
||||
name = _text(raw_article.get("name"), "article.name", required=False, limit=240)
|
||||
if number or name:
|
||||
article = {"number": number, "name": name}
|
||||
|
||||
roll_weight = None
|
||||
raw_weight = payload.get("roll_weight")
|
||||
if raw_weight is not None:
|
||||
if not isinstance(raw_weight, dict):
|
||||
raise ReportValidationError("roll_weight must be an object")
|
||||
weight_value = _positive_number(raw_weight.get("value"), "roll_weight.value")
|
||||
weight_display = _text(
|
||||
raw_weight.get("display"), "roll_weight.display", limit=40
|
||||
)
|
||||
roll_weight = {
|
||||
"value": weight_value,
|
||||
"display": _measurement_display(
|
||||
weight_value, weight_display, "kg", "roll_weight.display"
|
||||
),
|
||||
}
|
||||
|
||||
warnings = []
|
||||
raw_warnings = payload.get("warnings", [])
|
||||
if not isinstance(raw_warnings, list) or len(raw_warnings) > 12:
|
||||
raise ReportValidationError("warnings must be a list of at most 12 items")
|
||||
for index, item in enumerate(raw_warnings):
|
||||
if not isinstance(item, dict):
|
||||
raise ReportValidationError(f"warnings[{index}] must be an object")
|
||||
warnings.append(
|
||||
{
|
||||
"title": _text(item.get("title"), f"warnings[{index}].title", limit=120),
|
||||
"message": _text(item.get("message"), f"warnings[{index}].message", limit=500),
|
||||
"severity": (
|
||||
item.get("severity")
|
||||
if item.get("severity") in {"warning", "critical"}
|
||||
else "warning"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
notes = []
|
||||
raw_notes = payload.get("notes", [])
|
||||
if not isinstance(raw_notes, list) or len(raw_notes) > 12:
|
||||
raise ReportValidationError("notes must be a list of at most 12 items")
|
||||
for index, item in enumerate(raw_notes):
|
||||
notes.append(_text(item, f"notes[{index}]", limit=500))
|
||||
|
||||
report_text_size = sum(
|
||||
len(item["title"]) + len(item["message"]) for item in warnings
|
||||
) + sum(len(note) for note in notes)
|
||||
if report_text_size > 1800:
|
||||
raise ReportValidationError("warnings and notes are too long for a one-page report")
|
||||
|
||||
return {
|
||||
"article": article,
|
||||
"inputs": inputs,
|
||||
"diameters": diameters,
|
||||
"roll_length": roll_length,
|
||||
"roll_weight": roll_weight,
|
||||
"warnings": warnings,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
def safe_report_filename(article_number: str | None, generated_at: datetime) -> str:
|
||||
reference = re.sub(r"[^A-Za-z0-9._-]+", "-", article_number or "calculation")
|
||||
reference = reference.strip("-._")[:60] or "calculation"
|
||||
return f"roll-calculation_{reference}_{generated_at:%Y-%m-%d_%H%M%S}.pdf"
|
||||
|
||||
|
||||
def report_from_calculation_result(result: Any) -> dict[str, Any]:
|
||||
"""Adapt an authoritative calculation result to the existing PDF model."""
|
||||
if not isinstance(result, dict) or result.get("status") != "success":
|
||||
raise ReportValidationError("a successful calculation result is required")
|
||||
calculation = result.get("calculation")
|
||||
effective = result.get("effective_inputs")
|
||||
request = result.get("request")
|
||||
provenance = result.get("provenance")
|
||||
if not all(isinstance(item, dict) for item in (calculation, effective, request, provenance)):
|
||||
raise ReportValidationError("incomplete authoritative calculation result")
|
||||
if provenance.get("calculator") != "roll_calculation.calculate_roll":
|
||||
raise ReportValidationError("unknown calculation provenance")
|
||||
|
||||
def effective_value(field: str) -> Any:
|
||||
item = effective.get(field)
|
||||
if not isinstance(item, dict) or "value" not in item:
|
||||
raise ReportValidationError(f"effective input {field} is required")
|
||||
return item["value"]
|
||||
|
||||
length = effective_value("roll_length_m")
|
||||
core_diameter = effective_value("core_diameter_mm")
|
||||
core_type = effective_value("core_type")
|
||||
thickness = effective_value("thickness_mm")
|
||||
thickness_stddev = effective_value("thickness_stddev_mm")
|
||||
inputs = [
|
||||
{"label": "Core diameter", "value": f"{core_diameter:.1f} mm"},
|
||||
{"label": "Core type", "value": str(core_type)},
|
||||
{"label": "Product thickness", "value": f"{thickness:.3f} mm"},
|
||||
{"label": "Thickness stddev", "value": f"{thickness_stddev:.3f} mm"},
|
||||
{"label": "Product length", "value": f"{length:.2f} m"},
|
||||
]
|
||||
if request.get("category"):
|
||||
inputs.append({"label": "Product category", "value": request["category"]})
|
||||
if request.get("production_site"):
|
||||
inputs.append({"label": "Production site", "value": request["production_site"]})
|
||||
|
||||
weight = calculation.get("roll_weight_kg")
|
||||
if weight is not None:
|
||||
width = effective_value("width_m")
|
||||
area_weight = effective_value("area_weight_g_m2")
|
||||
inputs.extend(
|
||||
(
|
||||
{"label": "Roll width", "value": f"{width:.2f} m"},
|
||||
{"label": "Area weight", "value": f"{area_weight:.1f} g/m²"},
|
||||
)
|
||||
)
|
||||
|
||||
article = result.get("resolved_article")
|
||||
raw_report = {
|
||||
"report_type": "roll_diameter",
|
||||
"article": (
|
||||
{"number": article.get("number", ""), "name": article.get("name", "")}
|
||||
if isinstance(article, dict)
|
||||
else None
|
||||
),
|
||||
"inputs": inputs,
|
||||
"diameters": {
|
||||
"minimum": {
|
||||
"value": calculation.get("minimum_diameter_mm"),
|
||||
"display": f"{calculation.get('minimum_diameter_mm'):.1f} mm",
|
||||
},
|
||||
"average": {
|
||||
"value": calculation.get("average_diameter_mm"),
|
||||
"display": f"{calculation.get('average_diameter_mm'):.1f} mm",
|
||||
},
|
||||
"maximum": {
|
||||
"value": calculation.get("maximum_diameter_mm"),
|
||||
"display": f"{calculation.get('maximum_diameter_mm'):.1f} mm",
|
||||
},
|
||||
},
|
||||
"roll_length": {"value": length, "display": f"{length:.2f} m"},
|
||||
"roll_weight": (
|
||||
{"value": weight, "display": f"{weight:.1f} kg"}
|
||||
if weight is not None
|
||||
else None
|
||||
),
|
||||
"warnings": result.get("warnings", []),
|
||||
"notes": result.get("notes", []),
|
||||
}
|
||||
return validate_report_payload(raw_report)
|
||||
|
||||
|
||||
def _pdf_escape(value: str) -> bytes:
|
||||
value = value.translate(
|
||||
str.maketrans(
|
||||
{
|
||||
"≥": ">=",
|
||||
"≤": "<=",
|
||||
"−": "-",
|
||||
"σ": "sigma",
|
||||
"π": "pi",
|
||||
"✓": "OK",
|
||||
"✗": "X",
|
||||
}
|
||||
)
|
||||
)
|
||||
encoded = value.encode("cp1252", errors="replace")
|
||||
return encoded.replace(b"\\", b"\\\\").replace(b"(", b"\\(").replace(b")", b"\\)")
|
||||
|
||||
|
||||
def _wrap(value: str, width: float, font_size: float) -> list[str]:
|
||||
max_chars = max(12, int(width / (font_size * 0.52)))
|
||||
words = value.split()
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for word in words:
|
||||
while len(word) > max_chars:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = ""
|
||||
lines.append(word[: max_chars - 1] + "-")
|
||||
word = word[max_chars - 1 :]
|
||||
candidate = f"{current} {word}".strip()
|
||||
if len(candidate) <= max_chars:
|
||||
current = candidate
|
||||
else:
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current or not lines:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
class _Canvas:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[bytes] = []
|
||||
|
||||
def raw(self, command: str) -> None:
|
||||
self.commands.append(command.encode("ascii"))
|
||||
|
||||
def text(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
value: str,
|
||||
*,
|
||||
size: float = 9,
|
||||
bold: bool = False,
|
||||
color: tuple[float, float, float] = (0.16, 0.20, 0.18),
|
||||
) -> None:
|
||||
font = "F2" if bold else "F1"
|
||||
prefix = (
|
||||
f"BT /{font} {size:.2f} Tf "
|
||||
f"{color[0]:.3f} {color[1]:.3f} {color[2]:.3f} rg "
|
||||
f"1 0 0 1 {x:.2f} {y:.2f} Tm ("
|
||||
).encode("ascii")
|
||||
self.commands.append(prefix + _pdf_escape(value) + b") Tj ET")
|
||||
|
||||
def line(
|
||||
self,
|
||||
x1: float,
|
||||
y1: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
*,
|
||||
color=(0.8, 0.8, 0.8),
|
||||
width=0.7,
|
||||
) -> None:
|
||||
self.raw(
|
||||
f"{color[0]:.3f} {color[1]:.3f} {color[2]:.3f} RG {width:.2f} w "
|
||||
f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S"
|
||||
)
|
||||
|
||||
def rect(
|
||||
self, x: float, y: float, width: float, height: float, *, fill, stroke=None
|
||||
) -> None:
|
||||
command = f"{fill[0]:.3f} {fill[1]:.3f} {fill[2]:.3f} rg "
|
||||
if stroke:
|
||||
command += f"{stroke[0]:.3f} {stroke[1]:.3f} {stroke[2]:.3f} RG 0.8 w "
|
||||
command += f"{x:.2f} {y:.2f} {width:.2f} {height:.2f} re {'B' if stroke else 'f'}"
|
||||
self.raw(command)
|
||||
|
||||
def stream(self) -> bytes:
|
||||
return b"\n".join(self.commands) + b"\n"
|
||||
|
||||
|
||||
def _make_pdf(content: bytes) -> bytes:
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
(
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] "
|
||||
b"/Resources << /Font << /F1 4 0 R /F2 5 0 R >> /ExtGState << /GS1 6 0 R >> >> "
|
||||
b"/Contents 7 0 R >>"
|
||||
),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
|
||||
b"<< /Type /ExtGState /ca 0.085 /CA 0.085 >>",
|
||||
(
|
||||
b"<< /Length "
|
||||
+ str(len(content)).encode("ascii")
|
||||
+ b" >>\nstream\n"
|
||||
+ content
|
||||
+ b"endstream"
|
||||
),
|
||||
]
|
||||
|
||||
pdf = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
offsets = [0]
|
||||
for number, obj in enumerate(objects, start=1):
|
||||
offsets.append(len(pdf))
|
||||
pdf.extend(f"{number} 0 obj\n".encode("ascii"))
|
||||
pdf.extend(obj)
|
||||
pdf.extend(b"\nendobj\n")
|
||||
xref = len(pdf)
|
||||
pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
|
||||
pdf.extend(b"0000000000 65535 f \n")
|
||||
for offset in offsets[1:]:
|
||||
pdf.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
|
||||
pdf.extend(
|
||||
(
|
||||
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
|
||||
f"startxref\n{xref}\n%%EOF\n"
|
||||
).encode("ascii")
|
||||
)
|
||||
return bytes(pdf)
|
||||
|
||||
|
||||
def render_roll_report(
|
||||
report: dict[str, Any],
|
||||
*,
|
||||
generated_at: datetime,
|
||||
build_info: dict[str, str],
|
||||
) -> bytes:
|
||||
"""Render a validated report model as a deliberately compact one-page PDF."""
|
||||
canvas = _Canvas()
|
||||
green = (0.10, 0.43, 0.28)
|
||||
pale_green = (0.91, 0.97, 0.94)
|
||||
dark = (0.12, 0.18, 0.15)
|
||||
muted = (0.38, 0.43, 0.40)
|
||||
margin = 40
|
||||
usable_width = A4_WIDTH - (2 * margin)
|
||||
|
||||
canvas.rect(0, A4_HEIGHT - 92, A4_WIDTH, 92, fill=(0.08, 0.29, 0.20))
|
||||
canvas.text(margin, 805, "ROLL CALCULATOR", size=20, bold=True, color=(1, 1, 1))
|
||||
canvas.text(margin, 787, "Calculation report", size=10, color=(0.82, 0.93, 0.87))
|
||||
generated_text = generated_at.astimezone().strftime("Generated %Y-%m-%d %H:%M %Z")
|
||||
canvas.text(370, 805, generated_text, size=8, color=(0.88, 0.95, 0.91))
|
||||
canvas.text(
|
||||
370,
|
||||
790,
|
||||
(
|
||||
f"Version {build_info.get('version', 'unknown')} | "
|
||||
f"Build {build_info.get('commit', 'unknown')}"
|
||||
),
|
||||
size=8,
|
||||
color=(0.88, 0.95, 0.91),
|
||||
)
|
||||
|
||||
article = report["article"]
|
||||
y = 726
|
||||
if article:
|
||||
article_heading = " | ".join(
|
||||
part for part in (article["number"], article["name"]) if part
|
||||
)
|
||||
canvas.text(margin, y + 13, "ARTICLE", size=7.5, bold=True, color=green)
|
||||
article_lines = _wrap(article_heading, usable_width, 11)
|
||||
if len(article_lines) > 2:
|
||||
article_lines[1] = article_lines[1].rstrip("- ") + "…"
|
||||
for line in article_lines[:2]:
|
||||
canvas.text(margin, y, line, size=11, bold=True, color=dark)
|
||||
y -= 14
|
||||
y -= 5
|
||||
|
||||
canvas.text(margin, y, "CALCULATION RESULTS", size=8, bold=True, color=green)
|
||||
y -= 67
|
||||
card_gap = 10
|
||||
card_width = (usable_width - 3 * card_gap) / 4
|
||||
result_cards = (
|
||||
(("MINIMUM", "DIAMETER"), report["diameters"]["minimum"].display),
|
||||
(("AVERAGE", "DIAMETER"), report["diameters"]["average"].display),
|
||||
(("MAXIMUM", "DIAMETER"), report["diameters"]["maximum"].display),
|
||||
(("ROLL", "LENGTH"), report["roll_length"]["display"]),
|
||||
)
|
||||
for index, (label_lines, display) in enumerate(result_cards):
|
||||
x = margin + index * (card_width + card_gap)
|
||||
canvas.rect(x, y, card_width, 54, fill=pale_green, stroke=(0.55, 0.77, 0.65))
|
||||
canvas.text(x + 10, y + 39, label_lines[0], size=6.5, bold=True, color=green)
|
||||
canvas.text(x + 10, y + 31, label_lines[1], size=6.5, bold=True, color=green)
|
||||
canvas.text(
|
||||
x + 10,
|
||||
y + 10,
|
||||
display,
|
||||
size=13.5,
|
||||
bold=True,
|
||||
color=dark,
|
||||
)
|
||||
y -= 26
|
||||
|
||||
if report["roll_weight"]:
|
||||
canvas.text(margin, y, "CALCULATED ROLL WEIGHT", size=7.5, bold=True, color=green)
|
||||
canvas.text(
|
||||
margin + 146,
|
||||
y - 2,
|
||||
report["roll_weight"]["display"],
|
||||
size=12,
|
||||
bold=True,
|
||||
color=dark,
|
||||
)
|
||||
y -= 28
|
||||
|
||||
canvas.line(margin, y, A4_WIDTH - margin, y, color=(0.72, 0.78, 0.74))
|
||||
y -= 19
|
||||
canvas.text(margin, y, "INPUT DATA", size=8, bold=True, color=green)
|
||||
y -= 18
|
||||
column_width = (usable_width - 24) / 2
|
||||
row_height = 23
|
||||
inputs = report["inputs"]
|
||||
rows = math.ceil(len(inputs) / 2)
|
||||
for index, item in enumerate(inputs):
|
||||
column = index % 2
|
||||
row = index // 2
|
||||
x = margin + column * (column_width + 24)
|
||||
item_y = y - row * row_height
|
||||
canvas.text(x, item_y, item["label"].upper(), size=6.8, bold=True, color=muted)
|
||||
value = item["value"]
|
||||
if len(value) > 48:
|
||||
value = value[:47] + "…"
|
||||
canvas.text(x, item_y - 11, value, size=9.2, color=dark)
|
||||
y -= rows * row_height + 9
|
||||
|
||||
entries = [
|
||||
(item["title"], item["message"], item["severity"])
|
||||
for item in report["warnings"]
|
||||
]
|
||||
entries.extend(("Note", note, "note") for note in report["notes"])
|
||||
if entries:
|
||||
canvas.line(margin, y, A4_WIDTH - margin, y, color=(0.72, 0.78, 0.74))
|
||||
y -= 19
|
||||
canvas.text(margin, y, "WARNINGS & NOTES", size=8, bold=True, color=green)
|
||||
y -= 17
|
||||
available = max(72, y - 63)
|
||||
font_size = 8.2
|
||||
prepared = []
|
||||
while True:
|
||||
prepared = []
|
||||
total_lines = 0
|
||||
for title, message, severity in entries:
|
||||
title_lines = _wrap(title, usable_width - 20, font_size)
|
||||
message_lines = _wrap(message, usable_width - 20, font_size)
|
||||
prepared.append((title_lines, message_lines, severity))
|
||||
total_lines += len(title_lines) + len(message_lines)
|
||||
line_height = font_size + 2.2
|
||||
required = total_lines * line_height + len(entries) * 5
|
||||
if required <= available or font_size <= 5.6:
|
||||
break
|
||||
font_size -= 0.4
|
||||
if required > available:
|
||||
raise ReportValidationError(
|
||||
"warnings and notes do not fit on a one-page report"
|
||||
)
|
||||
line_height = font_size + 2.2
|
||||
for title_lines, message_lines, severity in prepared:
|
||||
if severity == "critical":
|
||||
color = (0.69, 0.21, 0.17)
|
||||
elif severity == "warning":
|
||||
color = (0.61, 0.40, 0.08)
|
||||
else:
|
||||
color = green
|
||||
for line in title_lines:
|
||||
canvas.text(margin + 8, y, line, size=font_size, bold=True, color=color)
|
||||
y -= line_height
|
||||
for line in message_lines:
|
||||
canvas.text(margin + 8, y, line, size=font_size, color=dark)
|
||||
y -= line_height
|
||||
y -= 5
|
||||
|
||||
footer_y = 34
|
||||
canvas.line(margin, footer_y + 21, A4_WIDTH - margin, footer_y + 21, color=(0.72, 0.78, 0.74))
|
||||
canvas.text(
|
||||
margin,
|
||||
footer_y + 7,
|
||||
(
|
||||
"Internal advisory calculation. Verify all inputs and outputs for "
|
||||
"plausibility before use."
|
||||
),
|
||||
size=7.2,
|
||||
color=muted,
|
||||
)
|
||||
canvas.text(
|
||||
A4_WIDTH - 160,
|
||||
footer_y + 7,
|
||||
f"Version {build_info.get('version', 'unknown')}",
|
||||
size=7.2,
|
||||
color=muted,
|
||||
)
|
||||
|
||||
# Repeated marks are painted into the page content stream, not attached as annotations/layers.
|
||||
canvas.raw("q /GS1 gs 0.65 0.70 0.67 rg")
|
||||
angle = math.radians(35)
|
||||
a = math.cos(angle)
|
||||
b = math.sin(angle)
|
||||
for x, mark_y in ((-40, 155), (120, 300), (-20, 445), (145, 590), (-15, 735)):
|
||||
canvas.raw(
|
||||
f"BT /F2 29 Tf {a:.5f} {b:.5f} {-b:.5f} {a:.5f} {x:.2f} {mark_y:.2f} Tm "
|
||||
"(INTERNAL USE ONLY) Tj ET"
|
||||
)
|
||||
canvas.raw("Q")
|
||||
return _make_pdf(canvas.stream())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
+220
-28
@@ -43,6 +43,9 @@
|
||||
.btn-calc:hover { background: #1F5438; }
|
||||
.btn-reset { background: #e0e0e0; color: #444; border: none; border-radius: 6px; padding: 11px 20px; font-size: 14px; cursor: pointer; margin-left: 10px; }
|
||||
.btn-reset:hover { background: #d0d0d0; }
|
||||
.btn-pdf { background: #1F5438; color: #fff; border: none; border-radius: 5px; padding: 9px 16px; font-size: 12px; font-weight: 700; cursor: pointer; margin-top: 13px; }
|
||||
.btn-pdf:hover:not(:disabled) { background: #153c28; }
|
||||
.btn-pdf:disabled { cursor: not-allowed; opacity: 0.48; }
|
||||
.result-box { background: linear-gradient(135deg, #D6EBE2 0%, #8CC9AB 100%); border: 2px solid #2BAC70; border-radius: 8px; padding: 16px 20px; margin-bottom: 18px; display: none; }
|
||||
.result-box.show { display: block; }
|
||||
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
|
||||
@@ -509,10 +512,9 @@
|
||||
<span class="unit-label">mm</span>
|
||||
</div>
|
||||
<div class="core-presets">
|
||||
<button class="core-preset-btn" data-core="133">133mm Stahl</button>
|
||||
<button class="core-preset-btn" data-core="150">150mm PVC</button>
|
||||
<button class="core-preset-btn" data-core="168">168mm PVC</button>
|
||||
<button class="core-preset-btn" data-core="194">194mm Stahl</button>
|
||||
{% for preset in core_presets %}
|
||||
<button class="core-preset-btn" data-core="{{ preset.ui_diameter }}" data-core-material="{{ preset.material }}">{{ preset.label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -613,6 +615,7 @@
|
||||
<div class="result-label" id="d-res-label">Result</div>
|
||||
<div class="result-value" id="d-res-val">–</div>
|
||||
<div class="result-range" id="d-res-range"></div>
|
||||
<button class="btn-pdf" id="d-pdf" type="button" disabled>Download PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="formula-box">
|
||||
@@ -880,6 +883,8 @@
|
||||
// =====================================================
|
||||
window.ARTICLE_DATA = [];
|
||||
window.APP_CONFIG = {"forklift_rules":{"applicable_categories":["bentofix"],"rules":[{"weight_threshold_kg":1700,"max_weight_kg":2750,"requirements":{"min_core_outer_diameter_mm":170},"messages":{"en":{"warning_title":"Heavy Roll – Special Equipment Required","core_ok":"Core diameter OK (≥ 170 mm)","core_nok":"Core diameter too small"}}}],"hard_limit":{"max_weight_kg":2750,"messages":{"en":{"block_title":"Weight Limit Exceeded","block_text":"Roll weight exceeds 2750 kg maximum"}}}}};
|
||||
window.CURRENT_ROLL_REQUEST = null;
|
||||
window.CURRENT_ROLL_RESULT = null;
|
||||
|
||||
// =====================================================
|
||||
// LOAD ARTICLE DATA
|
||||
@@ -936,6 +941,7 @@ function populateArticleDropdowns() {
|
||||
inp.value = val;
|
||||
pane.querySelectorAll('.core-preset-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
if (pane.id === 'tab-direct') invalidatePdfReport();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -982,7 +988,102 @@ document.querySelectorAll('.mode-btn').forEach(btn => {
|
||||
// =====================================================
|
||||
// DIRECT CALCULATION (WITH 2σ)
|
||||
// =====================================================
|
||||
document.getElementById('d-calc').addEventListener('click', () => {
|
||||
function currentArticle() {
|
||||
const rawValue = document.getElementById('d-art').value.trim();
|
||||
if (!rawValue) return null;
|
||||
const match = (window.ARTICLE_DATA || []).find(article =>
|
||||
rawValue === article.nr || rawValue.startsWith(article.nr + ' –')
|
||||
);
|
||||
if (match) return { number: String(match.nr), name: match.name || '' };
|
||||
const separator = rawValue.indexOf(' – ');
|
||||
if (separator >= 0) {
|
||||
return { number: rawValue.slice(0, separator), name: rawValue.slice(separator + 3) };
|
||||
}
|
||||
return { number: rawValue, name: '' };
|
||||
}
|
||||
|
||||
function currentCoreType(coreDiameter) {
|
||||
const activePreset = document.querySelector('#tab-direct .core-preset-btn.active');
|
||||
if (activePreset && Number(activePreset.dataset.core) === coreDiameter) return activePreset.textContent.trim();
|
||||
return 'Custom / not specified';
|
||||
}
|
||||
|
||||
function invalidatePdfReport() {
|
||||
window.CURRENT_ROLL_REQUEST = null;
|
||||
window.CURRENT_ROLL_RESULT = null;
|
||||
const button = document.getElementById('d-pdf');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Download PDF';
|
||||
}
|
||||
|
||||
function numericInput(id) {
|
||||
const raw = document.getElementById(id).value.trim();
|
||||
return raw === '' ? null : Number(raw);
|
||||
}
|
||||
|
||||
function buildCalculationRequest() {
|
||||
const article = currentArticle();
|
||||
const width = numericInput('d-wi');
|
||||
const areaWeight = numericInput('d-aw');
|
||||
return {
|
||||
intent: 'calculate_roll_diameter',
|
||||
article_number: article?.number || null,
|
||||
article_name_hint: article?.name || null,
|
||||
roll_length_m: numericInput('d-le'),
|
||||
width_m: width,
|
||||
thickness_mm: numericInput('d-th'),
|
||||
thickness_stddev_mm: numericInput('d-tol'),
|
||||
area_weight_g_m2: areaWeight,
|
||||
core_diameter_mm: numericInput('d-co'),
|
||||
core_type: currentCoreType(numericInput('d-co')),
|
||||
category: document.getElementById('d-cat').value || null,
|
||||
production_site: document.getElementById('d-site').value || null,
|
||||
include_roll_weight: width !== null && areaWeight !== null
|
||||
};
|
||||
}
|
||||
|
||||
function calculationIssueMessage(result) {
|
||||
if (result.status === 'article_not_found') return 'Article not found.';
|
||||
if (result.status === 'article_conflict') return 'Article number and name conflict.';
|
||||
if (result.status === 'needs_clarification') {
|
||||
const fields = [...(result.missing || []), ...(result.ambiguous || [])];
|
||||
return 'Please clarify: ' + fields.join(', ');
|
||||
}
|
||||
const issue = result.invalid?.[0]?.message;
|
||||
return issue ? 'Invalid calculation request: ' + issue : 'Calculation failed.';
|
||||
}
|
||||
|
||||
function renderAuthoritativeForkliftResult(result) {
|
||||
const box = document.getElementById('forkliftCheck');
|
||||
const titleEl = document.getElementById('forkliftCheckTitle');
|
||||
const bodyEl = document.getElementById('forkliftCheckBody');
|
||||
const warning = result.warnings?.[0];
|
||||
const note = result.notes?.[0];
|
||||
if (warning) {
|
||||
box.className = 'forklift-check ' + (warning.severity === 'critical' ? 'fc-block' : 'fc-warn') + ' show';
|
||||
titleEl.textContent = '⚠️ ' + warning.title;
|
||||
bodyEl.textContent = warning.message;
|
||||
} else if (note) {
|
||||
box.className = 'forklift-check fc-ok show';
|
||||
titleEl.textContent = '✓ Forklift Check OK';
|
||||
bodyEl.textContent = note;
|
||||
} else {
|
||||
box.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRollCalculation(calculationRequest) {
|
||||
const response = await fetch('/api/calculations/roll', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(calculationRequest)
|
||||
});
|
||||
if (!response.ok) throw new Error('Calculation service failed');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
document.getElementById('d-calc').addEventListener('click', async () => {
|
||||
invalidatePdfReport();
|
||||
const mode = document.querySelector('.mode-btn.active')?.dataset.mode || 'diameter';
|
||||
const d = parseFloat(document.getElementById('d-co').value) || 0;
|
||||
const t = parseFloat(document.getElementById('d-th').value) || 0;
|
||||
@@ -995,20 +1096,45 @@ document.getElementById('d-calc').addEventListener('click', () => {
|
||||
const resLabel = document.getElementById('d-res-label');
|
||||
const resVal = document.getElementById('d-res-val');
|
||||
const resRange = document.getElementById('d-res-range');
|
||||
let diameterResult = null;
|
||||
let authoritativeResult = null;
|
||||
let calculationRequest = null;
|
||||
|
||||
if (mode === 'diameter') {
|
||||
if (!d || !t || !L) { alert('Please enter d, t, and L.'); return; }
|
||||
const D_nom = Math.sqrt(d*d + (4*L*1000*t)/Math.PI);
|
||||
calculationRequest = buildCalculationRequest();
|
||||
try {
|
||||
authoritativeResult = await requestRollCalculation(calculationRequest);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
return;
|
||||
}
|
||||
if (authoritativeResult.status !== 'success') {
|
||||
alert(calculationIssueMessage(authoritativeResult));
|
||||
return;
|
||||
}
|
||||
const calculation = authoritativeResult.calculation;
|
||||
diameterResult = {
|
||||
minimum: calculation.minimum_diameter_mm,
|
||||
average: calculation.average_diameter_mm,
|
||||
maximum: calculation.maximum_diameter_mm,
|
||||
minus2Sigma: calculation.minimum_diameter_mm,
|
||||
plus2Sigma: calculation.maximum_diameter_mm,
|
||||
rollLength: calculation.effective_roll_length_m
|
||||
};
|
||||
resLabel.textContent = 'Roll Diameter (D)';
|
||||
resVal.textContent = D_nom.toFixed(1) + ' mm';
|
||||
resVal.textContent = diameterResult.average.toFixed(1) + ' mm';
|
||||
if (tol) {
|
||||
const D_minus2sigma = Math.sqrt(d*d + (4*L*1000*(t-2*tol))/Math.PI);
|
||||
const D_plus2sigma = Math.sqrt(d*d + (4*L*1000*(t+2*tol))/Math.PI);
|
||||
resRange.textContent = '−2σ: ' + D_minus2sigma.toFixed(1) + ' mm | +2σ: ' + D_plus2sigma.toFixed(1) + ' mm';
|
||||
resRange.textContent = '−2σ: ' + diameterResult.minus2Sigma.toFixed(1) + ' mm | +2σ: ' + diameterResult.plus2Sigma.toFixed(1) + ' mm';
|
||||
} else {
|
||||
resRange.textContent = '';
|
||||
}
|
||||
document.getElementById('d-di').value = D_nom.toFixed(1);
|
||||
document.getElementById('d-di').value = diameterResult.average.toFixed(1);
|
||||
if (authoritativeResult.effective_inputs.width_m?.source === 'article_master_data') {
|
||||
document.getElementById('d-wi').value = authoritativeResult.effective_inputs.width_m.value;
|
||||
}
|
||||
const authoritativeWeight = calculation.roll_weight_kg;
|
||||
document.getElementById('d-wo').value = authoritativeWeight === null ? '' : authoritativeWeight.toFixed(1);
|
||||
renderAuthoritativeForkliftResult(authoritativeResult);
|
||||
} else if (mode === 'length') {
|
||||
if (!d || !t || !D) { alert('Please enter d, t, and D.'); return; }
|
||||
const L_calc = (Math.PI / (4 * t)) * (D*D - d*d) / 1000;
|
||||
@@ -1074,17 +1200,24 @@ document.getElementById('d-calc').addEventListener('click', () => {
|
||||
document.getElementById('d-wo').value = W_result.toFixed(1);
|
||||
}
|
||||
|
||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
|
||||
if (wi > 0 && aw > 0 && L_final > 0) {
|
||||
const weight = aw * L_final * wi / 1000;
|
||||
document.getElementById('d-wo').value = weight.toFixed(1);
|
||||
checkForklift(weight, d, category);
|
||||
} else {
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
if (mode !== 'diameter') {
|
||||
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
|
||||
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
|
||||
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
|
||||
if (wi > 0 && aw > 0 && L_final > 0) {
|
||||
const rollWeight = aw * L_final * wi / 1000;
|
||||
document.getElementById('d-wo').value = rollWeight.toFixed(1);
|
||||
checkForklift(rollWeight, d, category);
|
||||
} else {
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'diameter' && authoritativeResult) {
|
||||
window.CURRENT_ROLL_REQUEST = calculationRequest;
|
||||
window.CURRENT_ROLL_RESULT = authoritativeResult;
|
||||
document.getElementById('d-pdf').disabled = false;
|
||||
}
|
||||
resultBox.style.display = 'block';
|
||||
});
|
||||
|
||||
@@ -1096,6 +1229,7 @@ document.getElementById('d-reset').addEventListener('click', () => {
|
||||
document.getElementById('d-art').value = '';
|
||||
document.getElementById('d-result').style.display = 'none';
|
||||
document.getElementById('forkliftCheck').classList.remove('show');
|
||||
invalidatePdfReport();
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
@@ -1141,33 +1275,91 @@ function checkForklift(weightKg, coreMm, category) {
|
||||
const cfg = window.APP_CONFIG?.forklift_rules;
|
||||
if (!cfg || !cfg.applicable_categories.includes(category.toLowerCase())) {
|
||||
box.classList.remove('show');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = cfg.rules[0];
|
||||
const limit = cfg.hard_limit;
|
||||
|
||||
if (weightKg > limit.max_weight_kg) {
|
||||
const message = limit.messages.en.block_text.replace('{weight}', weightKg);
|
||||
box.className = 'forklift-check fc-block show';
|
||||
titleEl.textContent = '⚠️ ' + limit.messages.en.block_title;
|
||||
bodyEl.innerHTML = '<p>' + limit.messages.en.block_text.replace('{weight}', weightKg) + '</p>';
|
||||
return;
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return {
|
||||
kind: 'warning',
|
||||
severity: 'critical',
|
||||
title: limit.messages.en.block_title,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
if (weightKg >= rule.weight_threshold_kg) {
|
||||
const coreOk = coreMm >= rule.requirements.min_core_outer_diameter_mm;
|
||||
const coreMessage = coreOk
|
||||
? 'Core diameter OK'
|
||||
: 'Core diameter ' + coreMm + ' mm (min 170 mm required)';
|
||||
const message = 'Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg. ' + coreMessage + '.';
|
||||
box.className = 'forklift-check fc-warn show';
|
||||
titleEl.textContent = '⚠️ ' + rule.messages.en.warning_title;
|
||||
const statusMsg = coreOk ? '<li class="fc-req-ok">✓ Core diameter OK</li>' : '<li class="fc-req-nok">✗ Core diameter ' + coreMm + ' mm (min 170 mm required)</li>';
|
||||
bodyEl.innerHTML = '<p>Special equipment required for rolls ≥ ' + rule.weight_threshold_kg + ' kg:</p><ul>' + statusMsg + '</ul>';
|
||||
return;
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return {
|
||||
kind: 'warning',
|
||||
severity: 'warning',
|
||||
title: rule.messages.en.warning_title,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
const message = 'Roll weight ' + weightKg.toFixed(1) + ' kg is within standard limits. No special equipment required.';
|
||||
box.className = 'forklift-check fc-ok show';
|
||||
titleEl.textContent = '✓ Forklift Check OK';
|
||||
bodyEl.innerHTML = '<p>Roll weight ' + weightKg.toFixed(1) + ' kg is within standard limits. No special equipment required.</p>';
|
||||
bodyEl.innerHTML = '<p>' + message + '</p>';
|
||||
return { kind: 'note', message };
|
||||
}
|
||||
|
||||
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
|
||||
// =====================================================
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,112 @@
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import app as rollcalc_app
|
||||
from conversation_service import ConversationService, InMemoryConversationStore
|
||||
from ollama_nlu import NLUResponse, OllamaUnavailableError, validate_nlu_payload
|
||||
|
||||
|
||||
class StaticNLUClient:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def interpret(
|
||||
self,
|
||||
message,
|
||||
*,
|
||||
has_state=False,
|
||||
expected_fields=(),
|
||||
current_state=None,
|
||||
):
|
||||
if isinstance(self.payload, Exception):
|
||||
raise self.payload
|
||||
return NLUResponse(
|
||||
interpretation=validate_nlu_payload(self.payload),
|
||||
raw_model_json="raw-json",
|
||||
latency_ms=1.0,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
|
||||
class ConversationApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.original_log_file = rollcalc_app.LOG_FILE
|
||||
self.original_service = rollcalc_app.CONVERSATION_SERVICE
|
||||
rollcalc_app.LOG_FILE = os.path.join(self.temp_dir.name, "access.json")
|
||||
rollcalc_app.app.config.update(TESTING=True)
|
||||
self.client = rollcalc_app.app.test_client()
|
||||
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
|
||||
self.headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
def tearDown(self):
|
||||
rollcalc_app.LOG_FILE = self.original_log_file
|
||||
rollcalc_app.CONVERSATION_SERVICE = self.original_service
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def configure(self, payload):
|
||||
rollcalc_app.CONVERSATION_SERVICE = ConversationService(
|
||||
StaticNLUClient(payload),
|
||||
store=InMemoryConversationStore(),
|
||||
build_info=rollcalc_app.BUILD_INFO,
|
||||
)
|
||||
|
||||
def create_conversation(self):
|
||||
response = self.client.post("/api/conversations", headers=self.headers)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
return response.get_json()["conversation_id"]
|
||||
|
||||
def test_conversation_success_exposes_authoritative_pdf(self):
|
||||
self.configure({
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": "150mm PVC",
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
})
|
||||
conversation_id = self.create_conversation()
|
||||
response = self.client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"message": "Berechnen"},
|
||||
headers=self.headers,
|
||||
)
|
||||
payload = response.get_json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(payload["status"], "success")
|
||||
self.assertEqual(
|
||||
payload["result"]["provenance"]["calculator"],
|
||||
"roll_calculation.calculate_roll",
|
||||
)
|
||||
|
||||
pdf = self.client.get(payload["pdf"]["url"], headers=self.headers)
|
||||
self.assertEqual(pdf.status_code, 200)
|
||||
self.assertEqual(pdf.mimetype, "application/pdf")
|
||||
self.assertEqual(pdf.data.count(b"/Type /Page "), 1)
|
||||
calculation = payload["result"]["calculation"]
|
||||
self.assertIn(
|
||||
f"{calculation['average_diameter_mm']:.1f} mm".encode("ascii"),
|
||||
pdf.data,
|
||||
)
|
||||
|
||||
def test_ollama_unavailable_returns_controlled_http_error(self):
|
||||
self.configure(OllamaUnavailableError("offline"))
|
||||
conversation_id = self.create_conversation()
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/conversations/{conversation_id}/messages",
|
||||
json={"message": "Berechnen"},
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.get_json()["status"], "nlu_error")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,852 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from conversation_service import ConversationService, InMemoryConversationStore
|
||||
from ollama_nlu import (
|
||||
NLUResponse,
|
||||
OllamaUnavailableError,
|
||||
NLUValidationError,
|
||||
validate_nlu_payload,
|
||||
)
|
||||
from roll_calculation import calculate_roll, modify_calculation
|
||||
|
||||
|
||||
def new_interpretation(**changes):
|
||||
payload = {
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": "150mm PVC",
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class QueueNLUClient:
|
||||
def __init__(self, *payloads):
|
||||
self.payloads = list(payloads)
|
||||
self.calls = []
|
||||
|
||||
def interpret(
|
||||
self,
|
||||
message,
|
||||
*,
|
||||
has_state=False,
|
||||
expected_fields=(),
|
||||
current_state=None,
|
||||
):
|
||||
self.calls.append({
|
||||
"message": message,
|
||||
"has_state": has_state,
|
||||
"expected_fields": expected_fields,
|
||||
"current_state": current_state,
|
||||
})
|
||||
payload = self.payloads.pop(0)
|
||||
if isinstance(payload, Exception):
|
||||
raise payload
|
||||
interpretation = validate_nlu_payload(payload)
|
||||
return NLUResponse(
|
||||
interpretation=interpretation,
|
||||
raw_model_json="raw-json",
|
||||
latency_ms=12.34,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
|
||||
class ConversationServiceTests(unittest.TestCase):
|
||||
def create_service(self, *payloads):
|
||||
client = QueueNLUClient(*payloads)
|
||||
service = ConversationService(
|
||||
client,
|
||||
store=InMemoryConversationStore(),
|
||||
build_info={"version": "test"},
|
||||
)
|
||||
return service, client, service.create_conversation()
|
||||
|
||||
def test_valid_new_calculation_calls_the_deterministic_core(self):
|
||||
service, _, conversation_id = self.create_service(new_interpretation())
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
deterministic_calculator.assert_called_once()
|
||||
self.assertEqual(
|
||||
response["result"]["provenance"]["calculator"],
|
||||
"roll_calculation.calculate_roll",
|
||||
)
|
||||
|
||||
def test_follow_up_length_modification_calls_modify_calculation(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "Mach daraus 80 m.")
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["calculation"]["effective_roll_length_m"],
|
||||
80.0,
|
||||
)
|
||||
self.assertTrue(client.calls[1]["has_state"])
|
||||
|
||||
def test_multi_field_follow_up_recalculates_once_and_creates_new_report(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(core_type="194mm Stahl", core_diameter_mm=194.0),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0, "include_roll_weight": True},
|
||||
},
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Ändere bitte die Rollenlänge auf 80m und berechne das Rollengewicht.",
|
||||
)
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(response["result"]["request"]["roll_length_m"], 80.0)
|
||||
self.assertTrue(response["result"]["request"]["include_roll_weight"])
|
||||
self.assertIsNotNone(response["result"]["calculation"]["roll_weight_kg"])
|
||||
self.assertNotEqual(response["pdf"]["report_id"], initial["pdf"]["report_id"])
|
||||
|
||||
def test_multi_field_length_and_width_follow_up(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 65.0, "width_m": 5.0},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
response = service.handle_message(conversation_id, "Nimm 65 m Länge und 5,0 m Breite.")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(response["result"]["request"]["roll_length_m"], 65.0)
|
||||
self.assertEqual(response["result"]["request"]["width_m"], 5.0)
|
||||
|
||||
def test_multi_field_core_preset_and_length_follow_up(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {
|
||||
"core_type": "194-mm-Stahlkern",
|
||||
"roll_length_m": 80.0,
|
||||
},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Nimm den 194-mm-Stahlkern und 80 m Länge.",
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["request"]
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_incomplete_new_weight_output_inherits_current_calculation(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=80.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=None,
|
||||
width_m=5.0,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
include_roll_weight=True,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Wieviel wiegt die Rolle bei 5,0 m Breite?",
|
||||
)
|
||||
|
||||
changes = deterministic_modifier.call_args.args[1]
|
||||
self.assertEqual(
|
||||
changes,
|
||||
{"width_m": 5.0, "include_roll_weight": True},
|
||||
)
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "180205")
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertEqual(request["width_m"], 5.0)
|
||||
self.assertTrue(request["include_roll_weight"])
|
||||
self.assertIsNotNone(response["result"]["calculation"]["roll_weight_kg"])
|
||||
self.assertIn("pdf", response)
|
||||
current_context = client.calls[1]["current_state"]
|
||||
self.assertTrue(current_context["has_successful_calculation"])
|
||||
self.assertEqual(current_context["article_number"], "180205")
|
||||
self.assertEqual(
|
||||
current_context["resolved_article_name"],
|
||||
"Bfix NSP 4900, 5,00 x 40 m",
|
||||
)
|
||||
self.assertEqual(current_context["roll_length_m"], 80.0)
|
||||
self.assertNotIn("calculation", current_context)
|
||||
|
||||
def test_incomplete_new_length_output_inherits_article_and_core(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=65.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "Wie sieht es bei 80 m aus?")
|
||||
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "180205")
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_explicit_new_article_does_not_inherit_current_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
roll_length_m=80.0,
|
||||
core_type="194mm Stahl",
|
||||
core_diameter_mm=194.0,
|
||||
),
|
||||
new_interpretation(
|
||||
article_number="146900",
|
||||
article_name_hint=None,
|
||||
roll_length_m=50.0,
|
||||
width_m=None,
|
||||
core_type="150mm PVC",
|
||||
core_diameter_mm=150.0,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung für Artikel 146900.",
|
||||
)
|
||||
|
||||
deterministic_calculator.assert_called_once()
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(request["article_number"], "146900")
|
||||
self.assertEqual(request["roll_length_m"], 50.0)
|
||||
self.assertEqual(request["core_diameter_mm"], 150.0)
|
||||
|
||||
def test_different_article_in_modify_output_starts_new_calculation(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"article_number": "146900"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.calculate_roll",
|
||||
wraps=calculate_roll,
|
||||
) as deterministic_calculator:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung für Artikel 146900.",
|
||||
)
|
||||
|
||||
deterministic_calculator.assert_called_once()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(response["result"]["request"]["article_number"], "146900")
|
||||
self.assertIsNone(response["result"]["request"]["roll_length_m"])
|
||||
self.assertIsNone(response["result"]["request"]["core_diameter_mm"])
|
||||
|
||||
def test_explicit_new_calculation_phrase_does_not_trigger_inheritance(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
),
|
||||
)
|
||||
service.handle_message(conversation_id, "Erste Berechnung")
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Neue Berechnung mit 80 m.",
|
||||
)
|
||||
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertIsNone(response["result"]["request"]["article_number"])
|
||||
self.assertIn("thickness_mm", response["result"]["missing"])
|
||||
|
||||
def test_no_current_state_does_not_inherit_incomplete_request(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint=None,
|
||||
roll_length_m=80.0,
|
||||
width_m=None,
|
||||
core_type=None,
|
||||
core_diameter_mm=None,
|
||||
)
|
||||
)
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "Rechne mit 80 m.")
|
||||
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertIn("thickness_mm", response["result"]["missing"])
|
||||
self.assertIn("core_diameter_mm", response["result"]["missing"])
|
||||
|
||||
def test_failed_followup_nlu_does_not_mutate_valid_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
NLUValidationError("invalid follow-up"),
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "Ungültige Änderung")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "nlu_error")
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
|
||||
def test_ambiguous_steel_core_uses_deterministic_clarification(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
valid_state = service.conversation_state(conversation_id).calculation_state
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Bitte ändere den Kern auf einen Stahlkern.",
|
||||
)
|
||||
record = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Es sind mehrere Stahlkerne hinterlegt. Welchen Kerndurchmesser "
|
||||
"soll ich verwenden: 133 mm oder 194 mm?",
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["allowed_values"],
|
||||
[133.0, 194.0],
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["core_family"],
|
||||
"steel",
|
||||
)
|
||||
self.assertEqual(record.calculation_state, valid_state)
|
||||
self.assertNotIn(
|
||||
"core_diameter_mm",
|
||||
response["interpretation"]["changes"],
|
||||
)
|
||||
|
||||
def test_ambiguous_pvc_core_lists_all_canonical_diameters(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "plastic core"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "Nimm einen PVC-Kern.")
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Es sind mehrere PVC-Kerne hinterlegt. Welchen Kerndurchmesser "
|
||||
"soll ich verwenden: 150 mm oder 168 mm?",
|
||||
)
|
||||
self.assertEqual(
|
||||
response["pending_clarification"]["allowed_values"],
|
||||
[150.0, 168.0],
|
||||
)
|
||||
|
||||
def test_valid_ambiguous_core_candidate_resolves_atomically(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
service.handle_message(conversation_id, "Nimm einen Stahlkern.")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "194 mm")
|
||||
|
||||
record = service.conversation_state(conversation_id)
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(len(client.calls), 2)
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertIsNone(record.pending_clarification)
|
||||
self.assertEqual(
|
||||
record.calculation_state["request"]["core_type"],
|
||||
"194mm Stahl",
|
||||
)
|
||||
self.assertEqual(
|
||||
record.calculation_state["request"]["core_diameter_mm"],
|
||||
194.0,
|
||||
)
|
||||
self.assertEqual(
|
||||
response["result"]["effective_inputs"]["core_diameter_mm"]["value"],
|
||||
194.0,
|
||||
)
|
||||
report_request = service.report_request(response["pdf"]["report_id"])
|
||||
self.assertEqual(report_request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(report_request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_ambiguous_multi_change_preserves_patch_until_core_selection(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
pending_response = service.handle_message(
|
||||
conversation_id,
|
||||
"Nimm 80 m und einen Stahlkern.",
|
||||
)
|
||||
pending = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(pending_response["status"], "needs_clarification")
|
||||
self.assertEqual(pending.calculation_state, before.calculation_state)
|
||||
self.assertEqual(
|
||||
pending.pending_clarification.requested_changes,
|
||||
{"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
)
|
||||
self.assertEqual(
|
||||
pending_response["pending_clarification"]["requested_changes"],
|
||||
{"roll_length_m": 80.0, "core_type": "Stahlkern"},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(conversation_id, "194 mm")
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["request"]
|
||||
self.assertEqual(request["roll_length_m"], 80.0)
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertNotEqual(response["pdf"]["report_id"], initial["pdf"]["report_id"])
|
||||
|
||||
def test_invalid_ambiguous_core_candidate_keeps_valid_state_and_pending(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "Stahlkern"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
valid_record = service.conversation_state(conversation_id)
|
||||
service.handle_message(conversation_id, "Nimm einen Stahlkern.")
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "200 mm")
|
||||
|
||||
record = service.conversation_state(conversation_id)
|
||||
modifier.assert_not_called()
|
||||
self.assertEqual(len(client.calls), 2)
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"200 mm ist für einen Stahlkern nicht hinterlegt. "
|
||||
"Verfügbar sind 133 mm und 194 mm.",
|
||||
)
|
||||
self.assertNotIn("pdf", response)
|
||||
self.assertEqual(record.calculation_state, valid_record.calculation_state)
|
||||
self.assertEqual(record.last_result, valid_record.last_result)
|
||||
self.assertEqual(record.pending_fields, ("core_diameter_mm",))
|
||||
self.assertEqual(
|
||||
record.pending_clarification.allowed_values,
|
||||
(133.0, 194.0),
|
||||
)
|
||||
|
||||
def test_explicit_steel_preset_alias_recalculates_and_updates_pdf_request(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "194 mm steel core"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
with patch(
|
||||
"conversation_service.modify_calculation",
|
||||
wraps=modify_calculation,
|
||||
) as deterministic_modifier:
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Bitte ändere den Kern auf 194 mm steel core.",
|
||||
)
|
||||
|
||||
deterministic_modifier.assert_called_once()
|
||||
self.assertEqual(response["status"], "success")
|
||||
request = response["result"]["state"]["request"]
|
||||
self.assertEqual(request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(request["core_diameter_mm"], 194.0)
|
||||
self.assertEqual(
|
||||
response["result"]["effective_inputs"]["core_type"]["value"],
|
||||
"194mm Stahl",
|
||||
)
|
||||
report_request = service.report_request(response["pdf"]["report_id"])
|
||||
self.assertEqual(report_request["core_type"], "194mm Stahl")
|
||||
self.assertEqual(report_request["core_diameter_mm"], 194.0)
|
||||
|
||||
def test_missing_width_is_filled_by_field_locked_clarification(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number="6001316",
|
||||
article_name_hint=None,
|
||||
include_roll_weight=True,
|
||||
),
|
||||
)
|
||||
clarification = service.handle_message(conversation_id, "Mit Gewicht")
|
||||
|
||||
self.assertEqual(clarification["status"], "needs_clarification")
|
||||
self.assertIn("Welche Breite", clarification["message"])
|
||||
self.assertIsNone(clarification["interpretation"]["width_m"])
|
||||
|
||||
response = service.handle_message(conversation_id, "4,9 m")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "parsed", "field": "width_m", "value": 4.9},
|
||||
)
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
|
||||
def test_pending_core_diameter_accepts_mm_reply_without_calling_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
initial = service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "150 mm")
|
||||
|
||||
self.assertEqual(initial["status"], "needs_clarification")
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["state"]["request"]["core_diameter_mm"],
|
||||
150.0,
|
||||
)
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
|
||||
def test_pending_core_diameter_accepts_unitless_reply(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "150")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["state"]["request"]["core_diameter_mm"],
|
||||
150.0,
|
||||
)
|
||||
|
||||
def test_incompatible_core_reply_keeps_state_result_and_pending_field(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(core_type=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
with patch("conversation_service.modify_calculation") as modifier:
|
||||
response = service.handle_message(conversation_id, "4,90 m")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["clarification"],
|
||||
{"status": "incompatible", "field": "core_diameter_mm"},
|
||||
)
|
||||
self.assertIn("Millimetern", response["message"])
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
self.assertEqual(after.pending_fields, ("core_diameter_mm",))
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
modifier.assert_not_called()
|
||||
|
||||
def test_pending_roll_length_accepts_meter_reply(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(roll_length_m=None)
|
||||
)
|
||||
service.handle_message(conversation_id, "Neue Berechnung")
|
||||
|
||||
response = service.handle_message(conversation_id, "80 m")
|
||||
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["result"]["calculation"]["effective_roll_length_m"],
|
||||
80.0,
|
||||
)
|
||||
|
||||
def test_article_conflict_remains_deterministic(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(article_name_hint="Stex R 1801")
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Konflikt")
|
||||
|
||||
self.assertEqual(response["status"], "article_conflict")
|
||||
self.assertIn("passen nicht zusammen", response["message"])
|
||||
|
||||
def test_ambiguous_article_lists_repository_candidates_with_dimensions(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(response["field"], "article")
|
||||
self.assertEqual(response["reason"], "ambiguous_article")
|
||||
self.assertEqual(
|
||||
response["candidates"],
|
||||
response["pending_clarification"]["candidates"],
|
||||
)
|
||||
self.assertEqual(response["pending_clarification"], {
|
||||
"field": "article",
|
||||
"reason": "ambiguous_article",
|
||||
"candidates": [
|
||||
{
|
||||
"article_number": "146900",
|
||||
"name": "Stex R 1801, 5,80 x 50 m",
|
||||
},
|
||||
{
|
||||
"article_number": "146910",
|
||||
"name": "Stex R 1801, 6,00 x 50 m",
|
||||
},
|
||||
],
|
||||
})
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Ich habe mehrere passende Artikel gefunden. Welchen meinst Du:\n"
|
||||
"146900 – Stex R 1801, 5,80 x 50 m\n"
|
||||
"oder\n"
|
||||
"146910 – Stex R 1801, 6,00 x 50 m?",
|
||||
)
|
||||
|
||||
def test_product_alias_clarification_uses_repository_candidates_only(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Secutex R501",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(
|
||||
conversation_id,
|
||||
"Welchen Durchmesser hat eine Secutex R501 mit 100m Länge?",
|
||||
)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
candidates = response["pending_clarification"]["candidates"]
|
||||
self.assertIn(
|
||||
{
|
||||
"article_number": "214507",
|
||||
"name": "Stex R 501 (Betonvlies), 5,20 x 100 m",
|
||||
},
|
||||
candidates,
|
||||
)
|
||||
self.assertTrue(
|
||||
all(candidate["article_number"].isdigit() for candidate in candidates)
|
||||
)
|
||||
|
||||
def test_article_candidate_number_followup_skips_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
response = service.handle_message(conversation_id, "146900")
|
||||
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
self.assertEqual(response["status"], "success")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "parsed", "field": "article", "value": "146900"},
|
||||
)
|
||||
self.assertEqual(response["result"]["resolved_article"]["number"], "146900")
|
||||
self.assertIsNone(
|
||||
service.conversation_state(conversation_id).pending_clarification
|
||||
)
|
||||
|
||||
def test_invalid_article_candidate_keeps_pending_state_without_ollama(self):
|
||||
service, client, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Stex R 1801",
|
||||
)
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "999999")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(
|
||||
response["interpretation"],
|
||||
{"status": "incompatible", "field": "article"},
|
||||
)
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_unresolved_article_ambiguity_does_not_mutate_valid_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"article_name_hint": "Stex R 1801"},
|
||||
},
|
||||
)
|
||||
service.handle_message(conversation_id, "Berechnen")
|
||||
before = service.conversation_state(conversation_id)
|
||||
|
||||
response = service.handle_message(conversation_id, "Nimm Stex R 1801")
|
||||
after = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "needs_clarification")
|
||||
self.assertEqual(after.calculation_state, before.calculation_state)
|
||||
self.assertEqual(after.last_result, before.last_result)
|
||||
self.assertEqual(after.pending_fields, ("article",))
|
||||
|
||||
def test_unknown_article_name_has_deterministic_not_found_message(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
new_interpretation(
|
||||
article_number=None,
|
||||
article_name_hint="Nicht vorhandenes Produkt XYZ",
|
||||
)
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
|
||||
self.assertEqual(response["status"], "article_not_found")
|
||||
self.assertEqual(
|
||||
response["message"],
|
||||
"Der angegebene Artikel wurde nicht gefunden.",
|
||||
)
|
||||
|
||||
def test_numeric_message_values_come_from_calculation_result(self):
|
||||
service, _, conversation_id = self.create_service(new_interpretation())
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
calculation = response["result"]["calculation"]
|
||||
|
||||
self.assertIn(
|
||||
f"Minimum: {calculation['minimum_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
self.assertIn(
|
||||
f"Mittelwert: {calculation['average_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
self.assertIn(
|
||||
f"Maximum: {calculation['maximum_diameter_mm']:.1f} mm",
|
||||
response["message"],
|
||||
)
|
||||
|
||||
def test_ollama_failure_is_controlled_and_does_not_mutate_state(self):
|
||||
service, _, conversation_id = self.create_service(
|
||||
OllamaUnavailableError("offline")
|
||||
)
|
||||
|
||||
response = service.handle_message(conversation_id, "Berechnen")
|
||||
record = service.conversation_state(conversation_id)
|
||||
|
||||
self.assertEqual(response["status"], "nlu_error")
|
||||
self.assertEqual(response["error"], "OllamaUnavailableError")
|
||||
self.assertIsNone(record.calculation_state)
|
||||
self.assertIsNone(record.last_result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import app as rollcalc_app
|
||||
from core_presets import CORE_PRESETS, matching_core_presets
|
||||
|
||||
|
||||
class CorePresetTests(unittest.TestCase):
|
||||
def test_rendered_web_ui_preset_list_is_unchanged(self):
|
||||
expected = [
|
||||
("133", "steel", "133mm Stahl"),
|
||||
("150", "pvc", "150mm PVC"),
|
||||
("168", "pvc", "168mm PVC"),
|
||||
("194", "steel", "194mm Stahl"),
|
||||
]
|
||||
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
log_file = os.path.join(temp_dir, "access.json")
|
||||
with patch.object(rollcalc_app, "LOG_FILE", log_file):
|
||||
response = rollcalc_app.app.test_client().get(
|
||||
"/",
|
||||
headers={"Authorization": f"Basic {credentials}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
rendered = re.findall(
|
||||
r'class="core-preset-btn" data-core="([^"]+)" '
|
||||
r'data-core-material="([^"]+)">([^<]+)</button>',
|
||||
response.get_data(as_text=True),
|
||||
)
|
||||
self.assertEqual(rendered, expected)
|
||||
self.assertEqual(
|
||||
[
|
||||
(preset.ui_diameter, preset.material, preset.label)
|
||||
for preset in CORE_PRESETS
|
||||
],
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_explicit_finite_aliases_resolve_one_canonical_preset(self):
|
||||
german = matching_core_presets("194 mm Stahlkern")
|
||||
english = matching_core_presets("194 mm steel core")
|
||||
|
||||
self.assertEqual(german, english)
|
||||
self.assertEqual(len(german), 1)
|
||||
self.assertEqual(german[0].key, "steel_194")
|
||||
|
||||
def test_generic_existing_families_are_genuinely_ambiguous(self):
|
||||
self.assertEqual(
|
||||
[preset.key for preset in matching_core_presets("Stahlkern")],
|
||||
["steel_133", "steel_194"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[preset.key for preset in matching_core_presets("plastic core")],
|
||||
["pvc_150", "pvc_168"],
|
||||
)
|
||||
|
||||
def test_unknown_core_label_does_not_match(self):
|
||||
self.assertEqual(matching_core_presets("cardboard core"), ())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,316 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from ollama_nlu import (
|
||||
NLUValidationError,
|
||||
OllamaConfig,
|
||||
OllamaNLUClient,
|
||||
OllamaResponseError,
|
||||
OllamaTimeoutError,
|
||||
OllamaUnavailableError,
|
||||
parse_clarification_reply,
|
||||
parse_nlu_json,
|
||||
validate_nlu_payload,
|
||||
)
|
||||
|
||||
|
||||
def new_calculation_payload(**changes):
|
||||
payload = {
|
||||
"intent": "new_calculation",
|
||||
"article_number": "180205",
|
||||
"article_name_hint": "Bentofix NSP 4900",
|
||||
"roll_length_m": 65.0,
|
||||
"width_m": None,
|
||||
"core_type": None,
|
||||
"core_diameter_mm": None,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
payload.update(changes)
|
||||
return payload
|
||||
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
|
||||
class RecordingOpener:
|
||||
def __init__(self, model_payload):
|
||||
self.model_payload = model_payload
|
||||
self.request_payload = None
|
||||
self.timeout = None
|
||||
|
||||
def __call__(self, request, *, timeout):
|
||||
self.request_payload = json.loads(request.data.decode("utf-8"))
|
||||
self.timeout = timeout
|
||||
response = {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": json.dumps(self.model_payload),
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
return FakeResponse(json.dumps(response).encode("utf-8"))
|
||||
|
||||
|
||||
class NLUValidationTests(unittest.TestCase):
|
||||
def test_core_diameter_clarification_accepts_mm_and_unitless_values(self):
|
||||
with_unit = parse_clarification_reply("150 mm", "core_diameter_mm")
|
||||
unitless = parse_clarification_reply("150", "core_diameter_mm")
|
||||
|
||||
self.assertEqual(
|
||||
with_unit.to_dict(),
|
||||
{"status": "parsed", "field": "core_diameter_mm", "value": 150.0},
|
||||
)
|
||||
self.assertEqual(unitless.to_dict(), with_unit.to_dict())
|
||||
|
||||
def test_core_diameter_clarification_rejects_meter_unit(self):
|
||||
interpretation = parse_clarification_reply("4,90 m", "core_diameter_mm")
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.to_dict(),
|
||||
{"status": "incompatible", "field": "core_diameter_mm"},
|
||||
)
|
||||
|
||||
def test_length_and_width_clarifications_use_expected_meter_field(self):
|
||||
length = parse_clarification_reply("80 m", "roll_length_m")
|
||||
width = parse_clarification_reply("4,90 m", "width_m")
|
||||
|
||||
self.assertEqual(length.value, 80.0)
|
||||
self.assertEqual(length.field, "roll_length_m")
|
||||
self.assertEqual(width.value, 4.9)
|
||||
self.assertEqual(width.field, "width_m")
|
||||
|
||||
def test_empty_optional_article_name_is_normalized_to_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint="")
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_whitespace_optional_article_name_is_normalized_to_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint=" ")
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_null_optional_article_name_remains_none(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint=None)
|
||||
)
|
||||
|
||||
self.assertIsNone(interpretation.article_name_hint)
|
||||
|
||||
def test_valid_optional_article_name_is_preserved(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
new_calculation_payload(article_name_hint="Bentofix NSP 4900")
|
||||
)
|
||||
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
|
||||
def test_empty_text_is_still_invalid_for_a_selected_modification(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": " "},
|
||||
}
|
||||
)
|
||||
|
||||
def test_new_calculation_extracts_requested_german_fields(self):
|
||||
interpretation = validate_nlu_payload(new_calculation_payload())
|
||||
|
||||
self.assertEqual(interpretation.intent, "new_calculation")
|
||||
self.assertEqual(interpretation.article_number, "180205")
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
self.assertEqual(interpretation.roll_length_m, 65.0)
|
||||
self.assertIsNone(interpretation.width_m)
|
||||
self.assertIsNone(interpretation.core_diameter_mm)
|
||||
|
||||
def test_length_modification_contains_only_the_requested_change(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.to_dict(),
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_length_m": 80.0},
|
||||
},
|
||||
)
|
||||
|
||||
def test_steel_core_modification_never_contains_a_guessed_diameter(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"core_type": "steel"},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(interpretation.changes, {"core_type": "steel"})
|
||||
self.assertNotIn("core_diameter_mm", interpretation.changes)
|
||||
|
||||
def test_modification_accepts_multiple_explicit_allowed_changes(self):
|
||||
interpretation = validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {
|
||||
"roll_length_m": 80.0,
|
||||
"width_m": 4.9,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
interpretation.changes,
|
||||
{"roll_length_m": 80.0, "width_m": 4.9},
|
||||
)
|
||||
|
||||
def test_forbidden_calculated_fields_are_rejected_at_every_level(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
new_calculation_payload(average_diameter_mm=999.0)
|
||||
)
|
||||
with self.assertRaises(NLUValidationError):
|
||||
validate_nlu_payload(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"roll_weight_kg": 1.0},
|
||||
}
|
||||
)
|
||||
|
||||
def test_invalid_json_is_handled_without_fallback_parsing(self):
|
||||
with self.assertRaises(NLUValidationError):
|
||||
parse_nlu_json("```json\n{}\n```")
|
||||
|
||||
|
||||
class OllamaClientTests(unittest.TestCase):
|
||||
def test_model_uses_available_default_and_environment_override(self):
|
||||
self.assertEqual(OllamaConfig().model, "qwen3.5:35B-A3B")
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ROLLCALC_OLLAMA_MODEL": "custom-qwen:model"},
|
||||
):
|
||||
self.assertEqual(
|
||||
OllamaConfig.from_env().model,
|
||||
"custom-qwen:model",
|
||||
)
|
||||
|
||||
def test_german_request_uses_constrained_non_thinking_chat_request(self):
|
||||
opener = RecordingOpener(new_calculation_payload())
|
||||
config = OllamaConfig(
|
||||
base_url="http://ollama.test:11434",
|
||||
model="custom-qwen:model",
|
||||
timeout_seconds=12,
|
||||
)
|
||||
client = OllamaNLUClient(config, opener=opener)
|
||||
|
||||
response = client.interpret(
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?"
|
||||
)
|
||||
|
||||
self.assertEqual(response.interpretation.article_number, "180205")
|
||||
self.assertEqual(response.interpretation.roll_length_m, 65.0)
|
||||
self.assertEqual(
|
||||
opener.request_payload["messages"][1]["content"],
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?",
|
||||
)
|
||||
self.assertEqual(opener.request_payload["model"], "custom-qwen:model")
|
||||
self.assertEqual(opener.request_payload["options"]["temperature"], 0.0)
|
||||
self.assertFalse(opener.request_payload["think"])
|
||||
self.assertFalse(opener.request_payload["stream"])
|
||||
self.assertIsInstance(opener.request_payload["format"], dict)
|
||||
self.assertIn(
|
||||
"never return an empty or whitespace-only string",
|
||||
opener.request_payload["messages"][0]["content"],
|
||||
)
|
||||
self.assertEqual(opener.timeout, 12)
|
||||
|
||||
def test_generated_ollama_schema_has_no_regex_patterns(self):
|
||||
opener = RecordingOpener(new_calculation_payload())
|
||||
|
||||
OllamaNLUClient(opener=opener).interpret("Artikel 180205 mit 65 Metern")
|
||||
|
||||
encoded_schema = json.dumps(opener.request_payload["format"])
|
||||
self.assertNotIn('"pattern"', encoded_schema)
|
||||
|
||||
def test_current_input_state_is_provided_without_calculated_results(self):
|
||||
opener = RecordingOpener(
|
||||
{
|
||||
"intent": "modify_calculation",
|
||||
"changes": {"width_m": 5.0},
|
||||
}
|
||||
)
|
||||
current_state = {
|
||||
"has_successful_calculation": True,
|
||||
"article_number": "180205",
|
||||
"roll_length_m": 80.0,
|
||||
"width_m": None,
|
||||
"core_type": "194mm Stahl",
|
||||
"core_diameter_mm": 194.0,
|
||||
"include_roll_weight": False,
|
||||
}
|
||||
|
||||
OllamaNLUClient(opener=opener).interpret(
|
||||
"Wieviel wiegt die Rolle bei 5 m Breite?",
|
||||
has_state=True,
|
||||
current_state=current_state,
|
||||
)
|
||||
|
||||
system_prompt = opener.request_payload["messages"][0]["content"]
|
||||
self.assertIn('"current_calculation":{', system_prompt)
|
||||
self.assertIn('"article_number":"180205"', system_prompt)
|
||||
self.assertIn('"roll_length_m":80.0', system_prompt)
|
||||
self.assertIn("die Rolle, dieselbe Rolle", system_prompt)
|
||||
self.assertNotIn("average_diameter_mm", system_prompt)
|
||||
self.assertNotIn("roll_weight_kg", system_prompt)
|
||||
|
||||
def test_timeout_and_unavailable_service_are_controlled(self):
|
||||
def timeout_opener(request, *, timeout):
|
||||
raise socket.timeout()
|
||||
|
||||
def unavailable_opener(request, *, timeout):
|
||||
raise URLError(ConnectionRefusedError())
|
||||
|
||||
with self.assertRaises(OllamaTimeoutError):
|
||||
OllamaNLUClient(opener=timeout_opener).interpret("Test")
|
||||
with self.assertRaises(OllamaUnavailableError):
|
||||
OllamaNLUClient(opener=unavailable_opener).interpret("Test")
|
||||
|
||||
def test_http_error_includes_status_and_ollama_response_detail(self):
|
||||
def bad_request_opener(request, *, timeout):
|
||||
raise HTTPError(
|
||||
request.full_url,
|
||||
400,
|
||||
"Bad Request",
|
||||
hdrs=None,
|
||||
fp=io.BytesIO(
|
||||
b'{"error":"some Ollama explanation"}'
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaises(OllamaResponseError) as raised:
|
||||
OllamaNLUClient(opener=bad_request_opener).interpret("Test")
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 400)
|
||||
self.assertIn("HTTP 400", str(raised.exception))
|
||||
self.assertIn("some Ollama explanation", str(raised.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from ollama_nlu import OllamaNLUClient
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.getenv("ROLLCALC_RUN_OLLAMA_SMOKE") == "1",
|
||||
"set ROLLCALC_RUN_OLLAMA_SMOKE=1 to use the local Ollama model",
|
||||
)
|
||||
class RealOllamaSmokeTests(unittest.TestCase):
|
||||
def test_representative_german_request(self):
|
||||
response = OllamaNLUClient().interpret(
|
||||
"Welchen Durchmesser hat Bentofix NSP 4900, "
|
||||
"Artikelnummer 180205 bei 65 m Länge?"
|
||||
)
|
||||
|
||||
interpretation = response.interpretation
|
||||
self.assertEqual(interpretation.intent, "new_calculation")
|
||||
self.assertEqual(interpretation.article_number, "180205")
|
||||
self.assertEqual(interpretation.article_name_hint, "Bentofix NSP 4900")
|
||||
self.assertEqual(interpretation.roll_length_m, 65.0)
|
||||
self.assertIsNone(interpretation.width_m)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,289 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class _BaseModel:
|
||||
def __init__(self, **values):
|
||||
for name in self.__class__.__annotations__:
|
||||
setattr(self, name, values.get(name, getattr(self.__class__, name)))
|
||||
|
||||
|
||||
class _SecretStr:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get_secret_value(self):
|
||||
return self._value
|
||||
|
||||
|
||||
def _field(*, default, **_kwargs):
|
||||
return default
|
||||
|
||||
|
||||
class _RequestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _TimeoutException(_RequestError):
|
||||
pass
|
||||
|
||||
|
||||
class _BasicAuth:
|
||||
def __init__(self, username, password):
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
|
||||
def _load_pipe_module():
|
||||
pydantic = types.ModuleType("pydantic")
|
||||
pydantic.BaseModel = _BaseModel
|
||||
pydantic.Field = _field
|
||||
pydantic.SecretStr = _SecretStr
|
||||
httpx = types.ModuleType("httpx")
|
||||
httpx.AsyncClient = object
|
||||
httpx.BasicAuth = _BasicAuth
|
||||
httpx.RequestError = _RequestError
|
||||
httpx.TimeoutException = _TimeoutException
|
||||
module_path = (
|
||||
Path(__file__).parents[1]
|
||||
/ "integrations"
|
||||
/ "openwebui"
|
||||
/ "rollcalc_pipe.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("rollcalc_pipe_test", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"pydantic": pydantic, "httpx": httpx}):
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
PIPE_MODULE = _load_pipe_module()
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code, payload):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeClientFactory:
|
||||
def __init__(self, responses=None, error=None):
|
||||
self.responses = list(responses or [])
|
||||
self.error = error
|
||||
self.calls = []
|
||||
self.options = []
|
||||
|
||||
def __call__(self, **options):
|
||||
self.options.append(options)
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def post(self, url, **options):
|
||||
self.calls.append((url, options))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class OpenWebUIPipeTests(unittest.IsolatedAsyncioTestCase):
|
||||
def configured_pipe(self, factory):
|
||||
pipe = PIPE_MODULE.Pipe(client_factory=factory)
|
||||
pipe.valves.ROLLCALC_API_BASE_URL = "http://rollcalc.internal:5000"
|
||||
pipe.valves.ROLLCALC_PUBLIC_BASE_URL = "https://rollcalc.example.test"
|
||||
pipe.valves.ROLLCALC_USERNAME = "service-user"
|
||||
pipe.valves.ROLLCALC_PASSWORD = _SecretStr("secret")
|
||||
return pipe
|
||||
|
||||
@staticmethod
|
||||
def context(chat_id="chat-1"):
|
||||
return {
|
||||
"body": {"messages": [{"role": "user", "content": "Fallback"}]},
|
||||
"__user__": {"id": "user-1"},
|
||||
"__metadata__": {
|
||||
"chat_id": chat_id,
|
||||
"user_prompt": "Berechne die Rolle",
|
||||
},
|
||||
}
|
||||
|
||||
async def test_same_openwebui_chat_reuses_rollcalc_conversation(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {"message": "Welcher Kern?"}),
|
||||
FakeResponse(200, {"message": "Berechnung abgeschlossen."}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
first = await pipe.pipe(**self.context())
|
||||
second_context = self.context()
|
||||
second_context["__metadata__"]["user_prompt"] = "150 mm"
|
||||
second = await pipe.pipe(**second_context)
|
||||
|
||||
self.assertEqual(first, "Welcher Kern?")
|
||||
self.assertEqual(second, "Berechnung abgeschlossen.")
|
||||
self.assertEqual(len(factory.calls), 3)
|
||||
self.assertEqual(
|
||||
factory.calls[1][0],
|
||||
"http://rollcalc.internal:5000/api/conversations/rollcalc-1/messages",
|
||||
)
|
||||
self.assertEqual(factory.calls[2][0], factory.calls[1][0])
|
||||
self.assertEqual(factory.calls[2][1]["json"], {"message": "150 mm"})
|
||||
self.assertEqual(factory.options[0]["auth"].username, "service-user")
|
||||
self.assertEqual(factory.options[0]["auth"].password, "secret")
|
||||
|
||||
async def test_different_chat_gets_different_rollcalc_conversation(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {"message": "Antwort 1"}),
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-2"}),
|
||||
FakeResponse(200, {"message": "Antwort 2"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
await pipe.pipe(**self.context("chat-1"))
|
||||
await pipe.pipe(**self.context("chat-2"))
|
||||
|
||||
self.assertEqual(len(pipe._conversations), 2)
|
||||
self.assertEqual(
|
||||
set(pipe._conversations.values()),
|
||||
{"rollcalc-1", "rollcalc-2"},
|
||||
)
|
||||
|
||||
async def test_only_deterministic_message_and_public_pdf_link_are_shown(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {
|
||||
"message": (
|
||||
"Berechnung abgeschlossen.\n\n"
|
||||
"Artikel: Test (180205)\n\n"
|
||||
"PDF: /api/conversations/reports/report-1.pdf"
|
||||
),
|
||||
"pdf": {
|
||||
"url": "/api/conversations/reports/report-1.pdf",
|
||||
},
|
||||
"interpretation": {"internal": "must not leak"},
|
||||
"result": {"calculation": {"average_diameter_mm": 999}},
|
||||
}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("Berechnung abgeschlossen.", response)
|
||||
self.assertIn(
|
||||
"[PDF herunterladen](https://rollcalc.example.test/"
|
||||
"api/conversations/reports/report-1.pdf)",
|
||||
response,
|
||||
)
|
||||
self.assertNotIn("interpretation", response)
|
||||
self.assertNotIn("999", response)
|
||||
self.assertNotIn("PDF: /api", response)
|
||||
|
||||
async def test_invalid_report_path_is_not_exposed(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "rollcalc-1"}),
|
||||
FakeResponse(200, {
|
||||
"message": "Berechnung abgeschlossen.",
|
||||
"pdf": {"url": "http://internal-secret/report.pdf"},
|
||||
}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertEqual(response, "Berechnung abgeschlossen.")
|
||||
|
||||
async def test_expired_conversation_is_removed_without_replaying_message(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "expired"}),
|
||||
FakeResponse(404, {"status": "conversation_not_found"}),
|
||||
FakeResponse(201, {"conversation_id": "fresh"}),
|
||||
FakeResponse(200, {"message": "Neue Sitzung"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
retry = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("abgelaufen", response)
|
||||
self.assertEqual(retry, "Neue Sitzung")
|
||||
self.assertEqual(len(factory.calls), 4)
|
||||
|
||||
async def test_timeout_is_friendly_and_does_not_expose_exception(self):
|
||||
factory = FakeClientFactory(error=_TimeoutException("private details"))
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("zu lange", response)
|
||||
self.assertNotIn("private", response)
|
||||
|
||||
async def test_authentication_and_ollama_errors_are_friendly(self):
|
||||
cases = (
|
||||
(401, "Authentifizierung"),
|
||||
(503, "Spracherkennung"),
|
||||
)
|
||||
for status_code, expected in cases:
|
||||
with self.subTest(status_code=status_code):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(status_code, {"debug": "must not leak"}),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn(expected, response)
|
||||
self.assertNotIn("debug", response)
|
||||
|
||||
async def test_validation_error_uses_the_api_deterministic_message(self):
|
||||
factory = FakeClientFactory([
|
||||
FakeResponse(201, {"conversation_id": "conversation"}),
|
||||
FakeResponse(
|
||||
400,
|
||||
{
|
||||
"status": "invalid_parameter",
|
||||
"message": "Die Eingabe für roll_length_m ist ungültig.",
|
||||
},
|
||||
),
|
||||
])
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertEqual(response, "Die Eingabe für roll_length_m ist ungültig.")
|
||||
|
||||
async def test_missing_configuration_does_not_make_http_request(self):
|
||||
factory = FakeClientFactory()
|
||||
pipe = PIPE_MODULE.Pipe(client_factory=factory)
|
||||
|
||||
response = await pipe.pipe(**self.context())
|
||||
|
||||
self.assertIn("nicht vollständig konfiguriert", response)
|
||||
self.assertEqual(factory.calls, [])
|
||||
|
||||
async def test_internal_openwebui_task_does_not_touch_conversation(self):
|
||||
factory = FakeClientFactory()
|
||||
pipe = self.configured_pipe(factory)
|
||||
|
||||
response = await pipe.pipe(
|
||||
**self.context(),
|
||||
__task__="title_generation",
|
||||
)
|
||||
|
||||
self.assertEqual(response, "RollCalc")
|
||||
self.assertEqual(factory.calls, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,231 @@
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from pdf_report import (
|
||||
ReportValidationError,
|
||||
report_from_calculation_result,
|
||||
render_roll_report,
|
||||
safe_report_filename,
|
||||
validate_report_payload,
|
||||
)
|
||||
from roll_calculation import calculate_roll
|
||||
|
||||
try:
|
||||
import app as rollcalc_app
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name not in {"flask", "flask_httpauth", "werkzeug"}:
|
||||
raise
|
||||
rollcalc_app = None
|
||||
|
||||
|
||||
def normal_payload(*, include_weight=True, include_warning=True):
|
||||
payload = {
|
||||
"report_type": "roll_diameter",
|
||||
"article": {"number": "146900", "name": "Stex R 1801, 5.80 x 50 m"},
|
||||
"inputs": [
|
||||
{"label": "Core diameter", "value": "150.0 mm"},
|
||||
{"label": "Core type", "value": "150mm PVC"},
|
||||
{"label": "Product thickness", "value": "10.885 mm"},
|
||||
{"label": "Thickness stddev", "value": "0.427 mm"},
|
||||
{"label": "Product length", "value": "50.00 m"},
|
||||
{"label": "Roll width", "value": "5.80 m"},
|
||||
{"label": "Area weight", "value": "1767.2 g/m²"},
|
||||
],
|
||||
"diameters": {
|
||||
"minimum": {"value": 612.3, "display": "612.3 mm"},
|
||||
"average": {"value": 635.7, "display": "635.7 mm"},
|
||||
"maximum": {"value": 658.1, "display": "658.1 mm"},
|
||||
},
|
||||
"roll_length": {"value": 50.0, "display": "50.00 m"},
|
||||
"roll_weight": (
|
||||
{"value": 512.5, "display": "512.5 kg"} if include_weight else None
|
||||
),
|
||||
"warnings": [],
|
||||
"notes": [],
|
||||
}
|
||||
if include_warning:
|
||||
payload["warnings"].append(
|
||||
{
|
||||
"severity": "warning",
|
||||
"title": "Heavy Roll - Special Equipment Required",
|
||||
"message": "Special equipment required. Core diameter too small.",
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def normal_request(*, include_weight=True):
|
||||
return {
|
||||
"intent": "calculate_roll_diameter",
|
||||
"article_number": "146900",
|
||||
"roll_length_m": 50.0,
|
||||
"width_m": 5.8 if include_weight else None,
|
||||
"core_diameter_mm": 150.0,
|
||||
"category": "bentofix",
|
||||
"include_roll_weight": include_weight,
|
||||
}
|
||||
|
||||
|
||||
@unittest.skipUnless(rollcalc_app, "Flask dependencies are not installed")
|
||||
class PdfEndpointTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.original_log_file = rollcalc_app.LOG_FILE
|
||||
rollcalc_app.LOG_FILE = os.path.join(self.temp_dir.name, "access.json")
|
||||
rollcalc_app.app.config.update(TESTING=True)
|
||||
self.client = rollcalc_app.app.test_client()
|
||||
credentials = base64.b64encode(b"mtazl:rollcalc").decode("ascii")
|
||||
self.auth_headers = {"Authorization": f"Basic {credentials}"}
|
||||
|
||||
def tearDown(self):
|
||||
rollcalc_app.LOG_FILE = self.original_log_file
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def post_report(self, payload):
|
||||
return self.client.post(
|
||||
"/api/reports/roll-calculation.pdf",
|
||||
json=payload,
|
||||
headers=self.auth_headers,
|
||||
)
|
||||
|
||||
def test_normal_request_is_recalculated_into_one_page_pdf(self):
|
||||
response = self.post_report(normal_request())
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.mimetype, "application/pdf")
|
||||
self.assertTrue(response.data.startswith(b"%PDF-1.4"))
|
||||
self.assertEqual(response.data.count(b"/Type /Page "), 1)
|
||||
self.assertIn(b"CALCULATION RESULTS", response.data)
|
||||
self.assertNotIn(b"CALCULATED DIAMETERS", response.data)
|
||||
for expected in (
|
||||
b"813.1 mm",
|
||||
b"845.9 mm",
|
||||
b"877.4 mm",
|
||||
b"50.00 m",
|
||||
b"512.5 kg",
|
||||
b"within standard limits",
|
||||
):
|
||||
self.assertIn(expected, response.data)
|
||||
self.assertGreaterEqual(response.data.count(b"INTERNAL USE ONLY"), 3)
|
||||
self.assertIn("roll-calculation_146900_", response.headers["Content-Disposition"])
|
||||
|
||||
def test_report_without_optional_weight_still_succeeds(self):
|
||||
response = self.post_report(
|
||||
normal_request(include_weight=False)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(b"CALCULATED ROLL WEIGHT", response.data)
|
||||
self.assertIn(b"845.9 mm", response.data)
|
||||
|
||||
def test_incomplete_or_caller_supplied_results_are_rejected(self):
|
||||
incomplete = normal_request()
|
||||
del incomplete["roll_length_m"]
|
||||
response = self.post_report(incomplete)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.mimetype, "application/json")
|
||||
|
||||
caller_result = normal_request()
|
||||
caller_result["diameters"] = {"average": 1}
|
||||
response = self.post_report(caller_result)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_endpoint_requires_authentication(self):
|
||||
response = self.client.post(
|
||||
"/api/reports/roll-calculation.pdf", json=normal_payload()
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
|
||||
class PdfReportModelTests(unittest.TestCase):
|
||||
def test_low_level_renderer_consumes_validated_layout_model(self):
|
||||
report = validate_report_payload(normal_payload())
|
||||
pdf = render_roll_report(
|
||||
report,
|
||||
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
|
||||
build_info={"version": "v0.4", "commit": "fe267b8"},
|
||||
)
|
||||
|
||||
self.assertIn(b"612.3 mm", pdf)
|
||||
self.assertIn(b"635.7 mm", pdf)
|
||||
self.assertIn(b"658.1 mm", pdf)
|
||||
self.assertIn(b"50.00 m", pdf)
|
||||
|
||||
def test_non_numeric_diameter_is_rejected(self):
|
||||
payload = normal_payload()
|
||||
payload["diameters"]["average"]["value"] = "635.7"
|
||||
with self.assertRaises(ReportValidationError):
|
||||
validate_report_payload(payload)
|
||||
|
||||
def test_display_value_mismatch_is_rejected(self):
|
||||
payload = normal_payload()
|
||||
payload["diameters"]["average"]["display"] = "999.9 mm"
|
||||
with self.assertRaises(ReportValidationError):
|
||||
validate_report_payload(payload)
|
||||
|
||||
def test_effective_roll_length_is_required_and_must_match_display(self):
|
||||
missing = normal_payload()
|
||||
del missing["roll_length"]
|
||||
with self.assertRaises(ReportValidationError):
|
||||
validate_report_payload(missing)
|
||||
|
||||
mismatched = normal_payload()
|
||||
mismatched["roll_length"]["display"] = "60.00 m"
|
||||
with self.assertRaises(ReportValidationError):
|
||||
validate_report_payload(mismatched)
|
||||
|
||||
def test_filename_is_filesystem_safe(self):
|
||||
filename = safe_report_filename(
|
||||
"Article 12/../../unsafe",
|
||||
datetime(2026, 8, 28, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(
|
||||
filename,
|
||||
"roll-calculation_Article-12-..-..-unsafe_2026-08-28_000000.pdf",
|
||||
)
|
||||
self.assertNotIn(":", filename)
|
||||
|
||||
def test_pdf_adapter_consumes_authoritative_calculation_result(self):
|
||||
result = calculate_roll(normal_request())
|
||||
report = report_from_calculation_result(result)
|
||||
pdf = render_roll_report(
|
||||
report,
|
||||
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
|
||||
build_info={"version": "v0.4", "commit": "fe267b8"},
|
||||
)
|
||||
|
||||
self.assertIn(b"845.9 mm", pdf)
|
||||
self.assertIn(b"50.00 m", pdf)
|
||||
self.assertIn(b"512.5 kg", pdf)
|
||||
|
||||
def test_multiple_warnings_are_wrapped_without_creating_a_second_page(self):
|
||||
payload = normal_payload()
|
||||
payload["warnings"] = [
|
||||
{
|
||||
"severity": "warning",
|
||||
"title": f"Warning {index}",
|
||||
"message": (
|
||||
"This complete engineering warning remains in the report and "
|
||||
"wraps when the available line width is reached."
|
||||
),
|
||||
}
|
||||
for index in range(1, 7)
|
||||
]
|
||||
report = validate_report_payload(payload)
|
||||
|
||||
pdf = render_roll_report(
|
||||
report,
|
||||
generated_at=datetime(2026, 8, 28, 12, 30, tzinfo=timezone.utc),
|
||||
build_info={"version": "v0.4", "commit": "fe267b8"},
|
||||
)
|
||||
|
||||
self.assertEqual(pdf.count(b"/Type /Page "), 1)
|
||||
for index in range(1, 7):
|
||||
self.assertIn(f"Warning {index}".encode("ascii"), pdf)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user