diff --git a/.env-devel b/.env-devel index 9980683c063..45157d71c56 100644 --- a/.env-devel +++ b/.env-devel @@ -144,6 +144,7 @@ DYNAMIC_SCHEDULER_UI_STORAGE_SECRET=adminadmin FUNCTION_SERVICES_AUTHORS='{"UN": {"name": "Unknown", "email": "unknown@osparc.io", "affiliation": "unknown"}}' +WEBSERVER_CHATBOT={} WEBSERVER_LICENSES={} WEBSERVER_FOGBUGZ={} LICENSES_ITIS_VIP_SYNCER_ENABLED=false diff --git a/services/docker-compose.yml b/services/docker-compose.yml index 63c0ad5b48c..6d8e1c40c3c 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -713,6 +713,7 @@ services: PROMETHEUS_URL: ${WEBSERVER_PROMETHEUS_URL} WEBSERVER_CATALOG: ${WEBSERVER_CATALOG} + WEBSERVER_CHATBOT: ${WEBSERVER_CHATBOT} # WEBSERVER_CREDIT_COMPUTATION WEBSERVER_CREDIT_COMPUTATION_ENABLED: ${WEBSERVER_CREDIT_COMPUTATION_ENABLED} @@ -923,6 +924,7 @@ services: WEBSERVER_ACTIVITY: ${WB_DB_EL_ACTIVITY} WEBSERVER_ANNOUNCEMENTS: ${WB_DB_EL_ANNOUNCEMENTS} WEBSERVER_CATALOG: ${WB_DB_EL_CATALOG} + WEBSERVER_CHATBOT: "null" WEBSERVER_CELERY: "null" WEBSERVER_DB_LISTENER: ${WB_DB_EL_DB_LISTENER} WEBSERVER_DIAGNOSTICS: ${WB_DB_EL_DIAGNOSTICS} @@ -1006,6 +1008,7 @@ services: WEBSERVER_ACTIVITY: ${WB_GC_ACTIVITY} WEBSERVER_ANNOUNCEMENTS: ${WB_GC_ANNOUNCEMENTS} WEBSERVER_CATALOG: ${WB_GC_CATALOG} + WEBSERVER_CHATBOT: "null" WEBSERVER_CELERY: "null" WEBSERVER_DB_LISTENER: ${WB_GC_DB_LISTENER} WEBSERVER_DIAGNOSTICS: ${WB_GC_DIAGNOSTICS} @@ -1082,6 +1085,7 @@ services: WEBSERVER_ACTIVITY: "null" WEBSERVER_ANNOUNCEMENTS: 0 WEBSERVER_CATALOG: "null" + WEBSERVER_CHATBOT: "null" WEBSERVER_CELERY: "null" WEBSERVER_DB_LISTENER: 0 WEBSERVER_DIRECTOR_V2: "null" diff --git a/services/web/server/src/simcore_service_webserver/application.py b/services/web/server/src/simcore_service_webserver/application.py index ba8e287880e..8e154029d67 100644 --- a/services/web/server/src/simcore_service_webserver/application.py +++ b/services/web/server/src/simcore_service_webserver/application.py @@ -36,7 +36,6 @@ from .dynamic_scheduler.plugin import setup_dynamic_scheduler from .email.plugin import setup_email from .exporter.plugin import setup_exporter -from .fogbugz.plugin import setup_fogbugz from .folders.plugin import setup_folders from .functions.plugin import setup_functions from .garbage_collector.plugin import setup_garbage_collector @@ -175,7 +174,6 @@ def create_application(tracing_config: TracingConfig) -> web.Application: setup_projects(app) # conversations - setup_fogbugz(app) # Needed for support conversations setup_conversations(app) # licenses diff --git a/services/web/server/src/simcore_service_webserver/application_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index 3d08171b8f9..44b3886712a 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -32,6 +32,7 @@ from ._meta import API_VERSION, API_VTAG, APP_NAME from .application_keys import APP_SETTINGS_APPKEY from .catalog.settings import CatalogSettings +from .chatbot.settings import ChatbotSettings from .collaboration.settings import RealTimeCollaborationSettings from .diagnostics.settings import DiagnosticsSettings from .director_v2.settings import DirectorV2Settings @@ -206,6 +207,12 @@ class ApplicationSettings(BaseApplicationSettings, MixinLoggingSettings): description="catalog service client's plugin", ), ] + WEBSERVER_CHATBOT: Annotated[ + ChatbotSettings | None, + Field( + json_schema_extra={"auto_default_from_env": True}, + ), + ] WEBSERVER_CELERY: Annotated[ CelerySettings | None, Field( diff --git a/services/web/server/src/simcore_service_webserver/chatbot/__init__.py b/services/web/server/src/simcore_service_webserver/chatbot/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py new file mode 100644 index 00000000000..89eaeb55874 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -0,0 +1,138 @@ +import logging +from typing import Annotated, Any, Final + +import httpx +from aiohttp import web +from pydantic import BaseModel, Field +from servicelib.aiohttp import status +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from tenacity import ( + retry, + retry_if_exception_type, + retry_if_result, + stop_after_attempt, + wait_exponential, +) + +from .settings import ChatbotSettings, get_plugin_settings + +_logger = logging.getLogger(__name__) + + +class ChatResponse(BaseModel): + answer: Annotated[str, Field(description="Answer from the chatbot")] + + +def _should_retry(response: httpx.Response | None) -> bool: + if response is None: + return True + return ( + response.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR + or response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + ) + + +_CHATBOT_RETRY = retry( + retry=( + retry_if_result(_should_retry) + | retry_if_exception_type( + ( + httpx.ConnectError, + httpx.TimeoutException, + httpx.NetworkError, + httpx.ProtocolError, + ) + ) + ), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, +) + + +class ChatbotRestClient: + def __init__(self, chatbot_settings: ChatbotSettings) -> None: + self._client = httpx.AsyncClient() + self._chatbot_settings = chatbot_settings + + async def get_settings(self) -> dict[str, Any]: + """Fetches chatbot settings""" + url = httpx.URL(self._chatbot_settings.base_url).join("/v1/chat/settings") + + @_CHATBOT_RETRY + async def _request() -> httpx.Response: + return await self._client.get(url) + + try: + response = await _request() + response.raise_for_status() + response_data: dict[str, Any] = response.json() + return response_data + except Exception: + _logger.error( # noqa: TRY400 + "Failed to fetch chatbot settings from %s", url + ) + raise + + async def ask_question(self, question: str) -> ChatResponse: + """Asks a question to the chatbot""" + url = httpx.URL(self._chatbot_settings.base_url).join("/v1/chat") + + @_CHATBOT_RETRY + async def _request() -> httpx.Response: + return await self._client.post( + url, + json={ + "question": question, + "llm": self._chatbot_settings.CHATBOT_LLM_MODEL, + "embedding_model": self._chatbot_settings.CHATBOT_EMBEDDING_MODEL, + }, + headers={ + "Content-Type": MIMETYPE_APPLICATION_JSON, + "Accept": MIMETYPE_APPLICATION_JSON, + }, + ) + + try: + response = await _request() + response.raise_for_status() + return ChatResponse.model_validate(response.json()) + except Exception: + _logger.error( # noqa: TRY400 + "Failed to ask question to chatbot at %s", url + ) + raise + + async def __aenter__(self): + """Async context manager entry""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit - cleanup client""" + await self._client.aclose() + + +_APPKEY: Final = web.AppKey(ChatbotRestClient.__name__, ChatbotRestClient) + + +async def setup_chatbot_rest_client(app: web.Application) -> None: + chatbot_settings = get_plugin_settings(app) + + client = ChatbotRestClient( + chatbot_settings=chatbot_settings, + ) + + app[_APPKEY] = client + + # Add cleanup on app shutdown + async def cleanup_chatbot_client(app: web.Application) -> None: + client = app.get(_APPKEY) + if client: + await client._client.aclose() # pylint: disable=protected-access # noqa: SLF001 + + app.on_cleanup.append(cleanup_chatbot_client) + + +def get_chatbot_rest_client(app: web.Application) -> ChatbotRestClient: + app_key: ChatbotRestClient = app[_APPKEY] + return app_key diff --git a/services/web/server/src/simcore_service_webserver/chatbot/chatbot_service.py b/services/web/server/src/simcore_service_webserver/chatbot/chatbot_service.py new file mode 100644 index 00000000000..a51f5bd24b6 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/chatbot_service.py @@ -0,0 +1,7 @@ +# mypy: disable-error-code=truthy-function +from ._client import ChatbotRestClient, get_chatbot_rest_client + +__all__ = [ + "get_chatbot_rest_client", + "ChatbotRestClient", +] diff --git a/services/web/server/src/simcore_service_webserver/chatbot/plugin.py b/services/web/server/src/simcore_service_webserver/chatbot/plugin.py new file mode 100644 index 00000000000..c8c24006ddb --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/plugin.py @@ -0,0 +1,20 @@ +import logging + +from aiohttp import web + +from ..application_setup import ModuleCategory, app_setup_func +from ..products.plugin import setup_products +from ._client import setup_chatbot_rest_client + +_logger = logging.getLogger(__name__) + + +@app_setup_func( + __name__, + ModuleCategory.ADDON, + settings_name="WEBSERVER_CHATBOT", + logger=_logger, +) +def setup_chatbot(app: web.Application): + setup_products(app) + app.on_startup.append(setup_chatbot_rest_client) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/settings.py b/services/web/server/src/simcore_service_webserver/chatbot/settings.py new file mode 100644 index 00000000000..e287f216157 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/settings.py @@ -0,0 +1,34 @@ +from functools import cached_property + +from aiohttp import web +from models_library.basic_types import PortInt +from pydantic_settings import SettingsConfigDict +from settings_library.base import BaseCustomSettings +from settings_library.utils_service import MixinServiceSettings, URLPart + +from ..application_keys import APP_SETTINGS_APPKEY + + +class ChatbotSettings(BaseCustomSettings, MixinServiceSettings): + model_config = SettingsConfigDict(str_strip_whitespace=True, str_min_length=1) + + CHATBOT_HOST: str + CHATBOT_PORT: PortInt + CHATBOT_LLM_MODEL: str = "gpt-3.5-turbo" + CHATBOT_EMBEDDING_MODEL: str = "openai/text-embedding-3-large" + + @cached_property + def base_url(self) -> str: + # http://chatbot:8000 + return self._compose_url( + prefix="CHATBOT", + port=URLPart.REQUIRED, + vtag=URLPart.EXCLUDE, + ) + + +def get_plugin_settings(app: web.Application) -> ChatbotSettings: + settings = app[APP_SETTINGS_APPKEY].WEBSERVER_CHATBOT + assert settings, "plugin.setup_chatbot not called?" # nosec + assert isinstance(settings, ChatbotSettings) # nosec + return settings diff --git a/services/web/server/src/simcore_service_webserver/conversations/plugin.py b/services/web/server/src/simcore_service_webserver/conversations/plugin.py index 5d9fec4915c..8aebaeffe51 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/plugin.py +++ b/services/web/server/src/simcore_service_webserver/conversations/plugin.py @@ -6,6 +6,7 @@ from ..application_keys import APP_SETTINGS_APPKEY from ..application_setup import ModuleCategory, app_setup_func +from ..chatbot.plugin import setup_chatbot from ..fogbugz.plugin import setup_fogbugz from ._controller import _conversations_messages_rest, _conversations_rest @@ -23,6 +24,7 @@ def setup_conversations(app: web.Application): assert app[APP_SETTINGS_APPKEY].WEBSERVER_CONVERSATIONS # nosec setup_fogbugz(app) + setup_chatbot(app) app.router.add_routes(_conversations_rest.routes) app.router.add_routes(_conversations_messages_rest.routes) diff --git a/services/web/server/tests/unit/with_dbs/04/test_chatbot_client.py b/services/web/server/tests/unit/with_dbs/04/test_chatbot_client.py new file mode 100644 index 00000000000..365ef932f6e --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/test_chatbot_client.py @@ -0,0 +1,72 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements + +from collections.abc import Iterator + +import httpx +import pytest +import respx +from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict +from pytest_simcore.helpers.typing_env import EnvVarsDict +from simcore_service_webserver.chatbot._client import ( + ChatResponse, + get_chatbot_rest_client, +) +from simcore_service_webserver.chatbot.settings import ChatbotSettings + + +@pytest.fixture +def app_environment( + monkeypatch: pytest.MonkeyPatch, + app_environment: EnvVarsDict, +): + return app_environment | setenvs_from_dict( + monkeypatch, + { + "CHATBOT_HOST": "chatbot", + "CHATBOT_PORT": "8000", + }, + ) + + +@pytest.fixture +def mocked_chatbot_api() -> Iterator[respx.MockRouter]: + _BASE_URL = "http://chatbot:8000" + + # Define responses in the order they will be called during the test + chatbot_answer_responses = [ + {"answer": "42"}, + ] + + with respx.mock(base_url=_BASE_URL) as mock: + # Create a side_effect that returns responses in sequence + mock.post(path="/v1/chat").mock( + side_effect=[ + httpx.Response(200, json=response) + for response in chatbot_answer_responses + ] + ) + yield mock + + +async def test_chatbot_client( + app_environment: EnvVarsDict, + client: TestClient, + mocked_chatbot_api: respx.MockRouter, +): + assert client.app + + settings = ChatbotSettings.create_from_envs() + assert settings.CHATBOT_HOST + assert settings.CHATBOT_PORT + + chatbot_client = get_chatbot_rest_client(client.app) + assert chatbot_client + + output = await chatbot_client.ask_question("What is the meaning of life?") + assert isinstance(output, ChatResponse) + assert output.answer == "42"