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
+164
View File
@@ -0,0 +1,164 @@
# OpenWebUI RollCalc Pipe
`rollcalc_pipe.py` registers **RollCalc Assistant** as a selectable OpenWebUI
Pipe model. It does not call an OpenWebUI chat model and contains no engineering
logic. Each user message is forwarded to the existing authenticated RollCalc
conversation API, and only its deterministic `message` plus a safe report link
is rendered.
## Deployment discovery
The OpenWebUI runtime was not visible from the coding sandbox: the Docker socket
was inaccessible, no native OpenWebUI files/processes were visible, and the
usual local ports were not reachable. Run these commands directly on
`fertigungski` before enabling the Pipe:
```bash
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
docker inspect <openwebui-container> --format '{{json .NetworkSettings.Networks}}'
docker exec <openwebui-container> python -c \
'from importlib.metadata import version; print(version("open-webui"))'
```
For a native installation, use:
```bash
systemctl list-units --type=service | grep -i open-webui
python3 -c 'from importlib.metadata import version; print(version("open-webui"))'
```
Also verify the actual route in both network contexts:
```bash
# From the OpenWebUI container/server namespace
curl -u "$ROLLCALC_USERNAME:$ROLLCALC_PASSWORD" \
"$ROLLCALC_API_BASE_URL/api/health"
# From a demo user's browser/network (run on a representative workstation)
curl -I "$ROLLCALC_PUBLIC_BASE_URL/api/conversations/reports/not-found.pdf"
```
An authenticated `404` for the deliberately invalid report is sufficient for
the second network check. A `401` confirms that the public route exists but the
browser has not authenticated yet.
## Install and enable
1. Open **Admin Panel → Workspace → Functions** in OpenWebUI.
2. Create/import a Function using the complete contents of
`integrations/openwebui/rollcalc_pipe.py`.
3. Save it and enable the Function.
4. Open its Valves and configure the values below.
5. Start a new chat and select **RollCalc Assistant** as the model.
The Pipe uses `pydantic` and asynchronous `httpx`, which are OpenWebUI runtime
dependencies; RollCalc's Python environment gains no package dependency.
## Configuration
Environment variables provide defaults; Function Valves can override them in
OpenWebUI:
| Variable / Valve | Purpose |
| --- | --- |
| `ROLLCALC_API_BASE_URL` | RollCalc base URL reachable by the OpenWebUI server/container. |
| `ROLLCALC_PUBLIC_BASE_URL` | RollCalc base URL reachable by the user's browser. |
| `ROLLCALC_USERNAME` | Basic-Auth user used by the Pipe for API calls. |
| `ROLLCALC_PASSWORD` | Basic-Auth password used by the Pipe for API calls. |
| `ROLLCALC_OPENWEBUI_TIMEOUT_SECONDS` | End-to-end request timeout; default `90`. |
| `ROLLCALC_OPENWEBUI_DEBUG` | Safe console diagnostics; default `false`. |
Do not put credentials into either URL. Store the password in the protected
OpenWebUI Valve/environment configuration and do not commit it.
`localhost` inside a container refers to that container. Typical internal URLs
are:
- `http://rollcalc:5000` when both services share a Docker network;
- `http://host.docker.internal:5000` when RollCalc runs on the host and the
Linux container has an explicit `host-gateway` mapping;
- the server's real internal DNS name when the services run on separate hosts.
The public URL must be the externally reachable origin/path, for example
`https://fertigungski.example/rollcalc`. It must never be an internal-only
container hostname.
Qwen remains configured on the RollCalc service, not in the Pipe. For the
validated local model, start RollCalc with:
```bash
export ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B
```
The existing `ROLLCALC_OLLAMA_URL`, timeout, temperature `0`, `think=false`,
and structured-output settings remain authoritative in `ollama_nlu.py`.
## Session and authentication behavior
The Pipe maps `(OpenWebUI user ID, OpenWebUI chat ID)` to one RollCalc
`conversation_id`. It serializes messages within one chat and allows different
chats to wait on Qwen independently. The mapping is process-local and is lost
when OpenWebUI reloads the Function or restarts. RollCalc's own conversation and
report stores are also process-local and are lost when RollCalc restarts.
If RollCalc reports an expired/unknown conversation, the Pipe removes the
mapping and asks the user to repeat a complete initial request. It deliberately
does not replay a follow-up into an empty technical state.
API calls authenticate server-to-server with the configured Basic-Auth account.
PDF links never contain those credentials. The existing PDF endpoint therefore
prompts the browser for RollCalc Basic Auth on first access. For the demo, open
and authenticate to `ROLLCALC_PUBLIC_BASE_URL` once in the same browser. Shared
SSO or a short-lived download token is not implemented.
Because all Pipe API calls use one service account, RollCalc's current access
log records that service identity rather than the individual OpenWebUI user.
## Demo procedure
For a repository-local validation, use separate terminals:
```bash
# Terminal 1
ollama serve
# Terminal 2
cd /opt/git-projects/RollCalcPython
ROLLCALC_OLLAMA_MODEL=qwen3.5:35B-A3B .venv/bin/python app.py
```
Start/restart the already-discovered OpenWebUI deployment using its actual
container or native service name; do not create a second deployment merely for
the Pipe. Configure and enable the Function as described above. Then open
`ROLLCALC_PUBLIC_BASE_URL` once and complete its Basic-Auth prompt.
Select **RollCalc Assistant** in OpenWebUI and use one chat for the entire
sequence:
```text
Welchen Durchmesser hat Bentofix NSP 4900, Artikelnummer 180205 bei 65 m Länge?
150 mm
Bitte ändere den Kern auf einen Stahlkern.
194 mm
Wie schwer ist die berechnete Rolle?
Ändere die Länge auf 80 m.
```
Select one of the candidate diameters actually offered by RollCalc if the preset
list differs. Verify that each successful recalculation has a new **PDF
herunterladen** link, that the weight remains visible after the final length
change, and that opening the links produces authenticated PDFs.
## Known demo limitations
- There is no streaming token output; the Pipe returns when the local Qwen plus
deterministic RollCalc request completes.
- Session mappings, RollCalc conversation state, and reports are in memory only.
- Multiple OpenWebUI or RollCalc workers do not share state.
- Browser and server-side Basic-Auth sessions are separate.
- RollCalc audit entries identify the configured Pipe service account, not the
originating OpenWebUI user.
- OpenWebUI direct API calls without a stable user/chat identifier are rejected
instead of sharing or guessing a session.
- OpenWebUI version, deployment topology, and the complete browser flow still
need verification in the actual `fertigungski` host namespace.
+346
View File
@@ -0,0 +1,346 @@
"""
title: RollCalc Assistant
author: RollCalc
version: 0.1.0
description: Deterministic RollCalc conversation API adapter
"""
from __future__ import annotations
import asyncio
import os
import re
from typing import Any, Callable
from urllib.parse import urljoin, urlsplit
import httpx
from pydantic import BaseModel, Field, SecretStr
def _environment_flag(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().casefold() in {"1", "true", "yes", "on"}
class Pipe:
"""Expose RollCalc as an OpenWebUI model without an LLM tool loop."""
class Valves(BaseModel):
ROLLCALC_API_BASE_URL: str = Field(
default=os.getenv("ROLLCALC_API_BASE_URL", ""),
description=(
"RollCalc URL reachable from the OpenWebUI server/container, "
"for example http://host.docker.internal:5000"
),
)
ROLLCALC_PUBLIC_BASE_URL: str = Field(
default=os.getenv("ROLLCALC_PUBLIC_BASE_URL", ""),
description=(
"RollCalc URL reachable from the user's browser; used only "
"for authenticated PDF links"
),
)
ROLLCALC_USERNAME: str = Field(
default=os.getenv("ROLLCALC_USERNAME", ""),
description="HTTP Basic Auth username used server-side",
)
ROLLCALC_PASSWORD: SecretStr = Field(
default=SecretStr(os.getenv("ROLLCALC_PASSWORD", "")),
description="HTTP Basic Auth password used server-side",
)
ROLLCALC_TIMEOUT_SECONDS: float = Field(
default=float(os.getenv("ROLLCALC_OPENWEBUI_TIMEOUT_SECONDS", "90")),
ge=1,
le=600,
description="End-to-end timeout for one RollCalc request",
)
ROLLCALC_DEBUG: bool = Field(
default=_environment_flag("ROLLCALC_OPENWEBUI_DEBUG"),
description="Log safe adapter diagnostics to the OpenWebUI console",
)
type = "pipe"
name = "RollCalc Assistant"
def __init__(
self,
client_factory: Callable[..., Any] | None = None,
) -> None:
self.valves = self.Valves()
self._client_factory = client_factory or httpx.AsyncClient
self._conversations: dict[tuple[str, str], str] = {}
self._chat_locks: dict[tuple[str, str], asyncio.Lock] = {}
self._lock_guard = asyncio.Lock()
async def pipe(
self,
body: dict[str, Any],
__user__: Any = None,
__metadata__: dict[str, Any] | None = None,
__chat_id__: str | None = None,
__task__: str | None = None,
) -> str:
"""Forward one browser message to one authoritative RollCalc session."""
if __task__:
return self._task_response(__task__)
configuration_error = self._configuration_error()
if configuration_error:
return configuration_error
message = self._user_message(body, __metadata__)
if not message:
return "Bitte gib eine RollCalc-Anfrage als Text ein."
session_key = self._session_key(
body,
__user__,
__metadata__,
__chat_id__,
)
if session_key is None:
return (
"Die RollCalc-Sitzung konnte diesem Chat nicht sicher zugeordnet "
"werden. Bitte verwende einen gespeicherten OpenWebUI-Chat."
)
chat_lock = await self._chat_lock(session_key)
async with chat_lock:
try:
return await self._send_message(session_key, message)
except httpx.TimeoutException:
return (
"RollCalc benötigt derzeit zu lange für eine Antwort. "
"Bitte versuche es erneut."
)
except httpx.RequestError:
return (
"RollCalc ist derzeit nicht erreichbar. "
"Bitte versuche es später erneut."
)
except (TypeError, ValueError):
return (
"RollCalc hat eine unerwartete Antwort geliefert. "
"Bitte versuche es erneut."
)
except Exception as error:
self._debug(f"unexpected adapter error: {error.__class__.__name__}")
return (
"Die RollCalc-Anfrage konnte nicht verarbeitet werden. "
"Bitte versuche es erneut."
)
async def _send_message(
self,
session_key: tuple[str, str],
message: str,
) -> str:
api_base = self.valves.ROLLCALC_API_BASE_URL.rstrip("/")
timeout = self.valves.ROLLCALC_TIMEOUT_SECONDS
auth = httpx.BasicAuth(
self.valves.ROLLCALC_USERNAME,
self._password(),
)
async with self._client_factory(timeout=timeout, auth=auth) as client:
conversation_id = self._conversations.get(session_key)
if conversation_id is None:
response = await client.post(f"{api_base}/api/conversations")
error = self._http_error(response)
if error:
return error
payload = self._json_object(response)
conversation_id = payload.get("conversation_id")
if not isinstance(conversation_id, str) or not conversation_id:
raise ValueError("missing conversation_id")
self._conversations[session_key] = conversation_id
response = await client.post(
f"{api_base}/api/conversations/{conversation_id}/messages",
json={"message": message},
)
if response.status_code == 404:
self._conversations.pop(session_key, None)
return (
"Die RollCalc-Sitzung ist abgelaufen. Bitte starte die "
"Berechnung mit deiner vollständigen Anfrage erneut."
)
error = self._http_error(response)
if error:
return error
payload = self._json_object(response)
response_message = payload.get("message")
if not isinstance(response_message, str) or not response_message.strip():
raise ValueError("missing user-facing message")
return self._render_response(response_message, payload.get("pdf"))
async def _chat_lock(self, key: tuple[str, str]) -> asyncio.Lock:
async with self._lock_guard:
lock = self._chat_locks.get(key)
if lock is None:
lock = asyncio.Lock()
self._chat_locks[key] = lock
return lock
def _configuration_error(self) -> str | None:
api_url = self.valves.ROLLCALC_API_BASE_URL.strip()
if not self._valid_base_url(api_url):
return (
"RollCalc ist in OpenWebUI noch nicht vollständig konfiguriert. "
"Bitte hinterlege eine gültige interne RollCalc-URL."
)
if not self.valves.ROLLCALC_USERNAME.strip() or not self._password():
return (
"Die RollCalc-Authentifizierung ist in OpenWebUI noch nicht "
"vollständig konfiguriert."
)
return None
@staticmethod
def _valid_base_url(value: str) -> bool:
parsed = urlsplit(value)
return (
parsed.scheme in {"http", "https"}
and bool(parsed.netloc)
and parsed.username is None
and parsed.password is None
)
def _password(self) -> str:
password = self.valves.ROLLCALC_PASSWORD
getter = getattr(password, "get_secret_value", None)
return getter() if callable(getter) else str(password)
@staticmethod
def _json_object(response: Any) -> dict[str, Any]:
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("JSON object required")
return payload
@staticmethod
def _http_error(response: Any) -> str | None:
if 200 <= response.status_code < 300:
return None
if response.status_code == 400:
try:
payload = response.json()
except Exception:
payload = None
message = payload.get("message") if isinstance(payload, dict) else None
if isinstance(message, str) and message.strip():
return message
return "Die Anfrage enthält ungültige Eingaben. Bitte prüfe sie."
if response.status_code in {401, 403}:
return (
"Die RollCalc-Authentifizierung ist nicht korrekt konfiguriert. "
"Bitte informiere die Administration."
)
if response.status_code == 503:
return (
"Die lokale Spracherkennung ist derzeit nicht erreichbar. "
"Bitte versuche es später erneut."
)
if response.status_code == 504:
return (
"Die lokale Spracherkennung benötigt derzeit zu lange. "
"Bitte versuche es erneut."
)
return (
"RollCalc konnte die Anfrage nicht verarbeiten. "
"Bitte versuche es erneut."
)
def _render_response(self, message: str, pdf: Any) -> str:
lines = message.strip().splitlines()
lines = [line for line in lines if not line.strip().startswith("PDF:")]
if isinstance(pdf, dict) and isinstance(pdf.get("url"), str):
report_path = pdf["url"]
if self._valid_report_path(report_path):
public_base = self.valves.ROLLCALC_PUBLIC_BASE_URL.strip()
if self._valid_base_url(public_base):
report_url = urljoin(
f"{public_base.rstrip('/')}/",
report_path.lstrip("/"),
)
lines.extend(("", f"[PDF herunterladen]({report_url})"))
else:
lines.extend((
"",
"PDF erstellt; öffentlicher Download-Link nicht "
"konfiguriert.",
))
return "\n".join(lines).strip()
@staticmethod
def _valid_report_path(value: str) -> bool:
return bool(
re.fullmatch(
r"/api/conversations/reports/[A-Za-z0-9_-]+\.pdf",
value,
)
)
@staticmethod
def _user_message(
body: dict[str, Any],
metadata: dict[str, Any] | None,
) -> str | None:
if isinstance(metadata, dict):
prompt = metadata.get("user_prompt")
if isinstance(prompt, str) and prompt.strip():
return prompt.strip()
messages = body.get("messages", []) if isinstance(body, dict) else []
if not isinstance(messages, list):
return None
for item in reversed(messages):
if not isinstance(item, dict) or item.get("role") != "user":
continue
content = item.get("content")
if isinstance(content, str) and content.strip():
return content.strip()
return None
@staticmethod
def _session_key(
body: dict[str, Any],
user: Any,
metadata: dict[str, Any] | None,
injected_chat_id: str | None,
) -> tuple[str, str] | None:
user_id = None
if isinstance(user, dict):
user_id = user.get("id") or user.get("email")
else:
user_id = getattr(user, "id", None) or getattr(user, "email", None)
if not user_id and isinstance(metadata, dict):
user_id = metadata.get("user_id")
chat_id = injected_chat_id
if isinstance(metadata, dict):
chat_id = (
chat_id
or metadata.get("chat_id")
or metadata.get("session_id")
)
if isinstance(body, dict):
chat_id = chat_id or body.get("chat_id") or body.get("session_id")
if not user_id or not chat_id:
return None
return str(user_id), str(chat_id)
@staticmethod
def _task_response(task: str) -> str:
normalized = task.casefold()
if "title" in normalized:
return "RollCalc"
if "emoji" in normalized:
return "🧮"
if "tag" in normalized or "follow" in normalized:
return "[]"
return ""
def _debug(self, message: str) -> None:
if self.valves.ROLLCALC_DEBUG:
print(f"[RollCalc OpenWebUI] {message}")