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
+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}")