feat: add conversational RollCalc assistant

This commit is contained in:
2026-08-29 22:37:56 +02:00
parent 3b44048250
commit fa3000b325
25 changed files with 6820 additions and 48 deletions
+192 -6
View File
@@ -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.