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