feat: add conversational RollCalc assistant
This commit is contained in:
@@ -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}")
|
||||
Reference in New Issue
Block a user