From 945ca4fd245ca6e0aa5febbe84a9814cb6943c46 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 14 Oct 2025 11:47:57 +0200 Subject: [PATCH 01/14] introudce chatbot client skeleton --- .../chatbot/__init__.py | 8 ++ .../chatbot/_client.py | 108 ++++++++++++++++++ .../chatbot/plugin.py | 22 ++++ .../chatbot/settings.py | 17 +++ 4 files changed, 155 insertions(+) create mode 100644 services/web/server/src/simcore_service_webserver/chatbot/__init__.py create mode 100644 services/web/server/src/simcore_service_webserver/chatbot/_client.py create mode 100644 services/web/server/src/simcore_service_webserver/chatbot/plugin.py create mode 100644 services/web/server/src/simcore_service_webserver/chatbot/settings.py 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..858a37c032f --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/__init__.py @@ -0,0 +1,8 @@ +# mypy: disable-error-code=truthy-function +from ._client import ChatbotQuestionCreate, ChatbotRestClient, get_chatbot_rest_client + +__all__ = [ + "get_chatbot_rest_client", + "ChatbotQuestionCreate", + "ChatbotRestClient", +] 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..d977684fe70 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -0,0 +1,108 @@ +"""Interface to communicate with Fogbugz API + +- Simple client to create cases in Fogbugz +""" + +import logging +from typing import Any, Final +from urllib.parse import urljoin + +import httpx +from aiohttp import web +from pydantic import AnyUrl, BaseModel, Field +from servicelib.aiohttp import status +from tenacity import ( + retry, + retry_if_exception_type, + retry_if_result, + stop_after_attempt, + wait_exponential, +) + +from .settings import get_plugin_settings + +_logger = logging.getLogger(__name__) + +_JSON_CONTENT_TYPE = "application/json" +_UNKNOWN_ERROR_MESSAGE = "Unknown error occurred" + + +class ChatbotQuestionCreate(BaseModel): + fogbugz_project_id: int = Field(description="Project ID in Fogbugz") + title: str = Field(description="Case title") + description: str = Field(description="Case description/first comment") + + +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 + ) + + +class ChatbotRestClient: + def __init__(self, host: AnyUrl, port: int) -> None: + self._client = httpx.AsyncClient() + self.host = host + self.port = port + self._base_url = f"{self.host}:{self.port}" + + async def get_settings(self) -> dict[str, Any]: + """Fetches chatbot settings""" + url = urljoin(f"{self._base_url}", "/v1/chat/settings") + + @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, + ) + 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 __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: + settings = get_plugin_settings(app) + + client = ChatbotRestClient(host=settings.CHATBOT_HOST, port=settings.CHATBOT_PORT) + + app[_APPKEY] = 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/plugin.py b/services/web/server/src/simcore_service_webserver/chatbot/plugin.py new file mode 100644 index 00000000000..a1a6968874d --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/plugin.py @@ -0,0 +1,22 @@ +"""tags management subsystem""" + +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..a73cff705c2 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/chatbot/settings.py @@ -0,0 +1,17 @@ +from aiohttp import web +from pydantic import AnyUrl +from settings_library.base import BaseCustomSettings + +from ..application_keys import APP_SETTINGS_APPKEY + + +class ChatbotSettings(BaseCustomSettings): + CHATBOT_HOST: AnyUrl + CHATBOT_PORT: int + + +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 From 39728283079e766632878f7cd426a1f05d271896 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 14 Oct 2025 17:18:13 +0200 Subject: [PATCH 02/14] introudce chatbot client skeleton --- .../simcore_service_webserver/application.py | 2 - .../application_settings.py | 8 ++ .../chatbot/__init__.py | 3 +- .../chatbot/_client.py | 87 +++++++++++++------ .../chatbot/settings.py | 17 +++- .../conversations/plugin.py | 2 + .../unit/with_dbs/04/test_chatbot_client.py | 74 ++++++++++++++++ 7 files changed, 158 insertions(+), 35 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/04/test_chatbot_client.py 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..3c7e38409da 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 @@ -260,6 +261,13 @@ class ApplicationSettings(BaseApplicationSettings, MixinLoggingSettings): ), ] + WEBSERVER_CHATBOT: Annotated[ + ChatbotSettings | None, + Field( + json_schema_extra={"auto_default_from_env": True}, + ), + ] + WEBSERVER_GARBAGE_COLLECTOR: Annotated[ GarbageCollectorSettings | 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 index 858a37c032f..a51f5bd24b6 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/__init__.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/__init__.py @@ -1,8 +1,7 @@ # mypy: disable-error-code=truthy-function -from ._client import ChatbotQuestionCreate, ChatbotRestClient, get_chatbot_rest_client +from ._client import ChatbotRestClient, get_chatbot_rest_client __all__ = [ "get_chatbot_rest_client", - "ChatbotQuestionCreate", "ChatbotRestClient", ] diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py index d977684fe70..ca15342db8d 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_client.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -9,7 +9,7 @@ import httpx from aiohttp import web -from pydantic import AnyUrl, BaseModel, Field +from pydantic import BaseModel, Field from servicelib.aiohttp import status from tenacity import ( retry, @@ -23,14 +23,12 @@ _logger = logging.getLogger(__name__) + _JSON_CONTENT_TYPE = "application/json" -_UNKNOWN_ERROR_MESSAGE = "Unknown error occurred" -class ChatbotQuestionCreate(BaseModel): - fogbugz_project_id: int = Field(description="Project ID in Fogbugz") - title: str = Field(description="Case title") - description: str = Field(description="Case description/first comment") +class ChatResponse(BaseModel): + answer: str = Field(description="Answer from the chatbot") def _should_retry(response: httpx.Response | None) -> bool: @@ -42,33 +40,36 @@ def _should_retry(response: httpx.Response | None) -> bool: ) +def _chatbot_retry(): + """Retry configuration for chatbot API calls""" + return 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, host: AnyUrl, port: int) -> None: + def __init__(self, base_url: str) -> None: self._client = httpx.AsyncClient() - self.host = host - self.port = port - self._base_url = f"{self.host}:{self.port}" + self._base_url = base_url async def get_settings(self) -> dict[str, Any]: """Fetches chatbot settings""" url = urljoin(f"{self._base_url}", "/v1/chat/settings") - @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, - ) + @_chatbot_retry() async def _request() -> httpx.Response: return await self._client.get(url) @@ -83,6 +84,36 @@ async def _request() -> httpx.Response: ) raise + async def ask_question(self, question: str) -> ChatResponse: + """Asks a question to the chatbot""" + url = urljoin(f"{self._base_url}", "/v1/chat") + + @_chatbot_retry() + async def _request() -> httpx.Response: + return await self._client.post( + url, + json={ + "question": question, + "llm": "gpt-3.5-turbo", + "embedding_model": "openai/text-embedding-3-large", + }, + headers={ + "Content-Type": _JSON_CONTENT_TYPE, + "Accept": _JSON_CONTENT_TYPE, + }, + ) + + try: + response = await _request() + response.raise_for_status() + response_data: dict[str, Any] = response.json() + return ChatResponse(**response_data) + 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 @@ -96,9 +127,9 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): async def setup_chatbot_rest_client(app: web.Application) -> None: - settings = get_plugin_settings(app) + chatbot_settings = get_plugin_settings(app) - client = ChatbotRestClient(host=settings.CHATBOT_HOST, port=settings.CHATBOT_PORT) + client = ChatbotRestClient(base_url=chatbot_settings.base_url) app[_APPKEY] = 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 index a73cff705c2..de8977782f8 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/settings.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/settings.py @@ -1,14 +1,25 @@ +from functools import cached_property + from aiohttp import web -from pydantic import AnyUrl from settings_library.base import BaseCustomSettings +from settings_library.utils_service import MixinServiceSettings, URLPart from ..application_keys import APP_SETTINGS_APPKEY -class ChatbotSettings(BaseCustomSettings): - CHATBOT_HOST: AnyUrl +class ChatbotSettings(BaseCustomSettings, MixinServiceSettings): + CHATBOT_HOST: str CHATBOT_PORT: int + @cached_property + def base_url(self) -> str: + # http://chatbot:8000/v1 + 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 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..4692dc9918f --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/test_chatbot_client.py @@ -0,0 +1,74 @@ +# 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_mock import MockerFixture +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, + mocker: MockerFixture, +): + 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" From f5f8b9f96882427c787a8be763bd49c47553d8a5 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 14 Oct 2025 17:20:24 +0200 Subject: [PATCH 03/14] fix --- .../server/src/simcore_service_webserver/chatbot/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/settings.py b/services/web/server/src/simcore_service_webserver/chatbot/settings.py index de8977782f8..40d34b6af99 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/settings.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/settings.py @@ -13,7 +13,7 @@ class ChatbotSettings(BaseCustomSettings, MixinServiceSettings): @cached_property def base_url(self) -> str: - # http://chatbot:8000/v1 + # http://chatbot:8000 return self._compose_url( prefix="CHATBOT", port=URLPart.REQUIRED, From 81dcbe4986430a28e681e7afdcafa0d6cb57780f Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 09:23:24 +0200 Subject: [PATCH 04/14] improve --- .../simcore_service_webserver/chatbot/_client.py | 14 +++++++++----- .../simcore_service_webserver/chatbot/settings.py | 2 ++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py index ca15342db8d..56559ef9a1a 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_client.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -19,7 +19,7 @@ wait_exponential, ) -from .settings import get_plugin_settings +from .settings import ChatbotSettings, get_plugin_settings _logger = logging.getLogger(__name__) @@ -61,9 +61,10 @@ def _chatbot_retry(): class ChatbotRestClient: - def __init__(self, base_url: str) -> None: + def __init__(self, base_url: str, chatbot_settings: ChatbotSettings) -> None: self._client = httpx.AsyncClient() self._base_url = base_url + self._chatbot_settings = chatbot_settings async def get_settings(self) -> dict[str, Any]: """Fetches chatbot settings""" @@ -94,8 +95,8 @@ async def _request() -> httpx.Response: url, json={ "question": question, - "llm": "gpt-3.5-turbo", - "embedding_model": "openai/text-embedding-3-large", + "llm": self._chatbot_settings.CHATBOT_LLM_MODEL, + "embedding_model": self._chatbot_settings.CHATBOT_EMBEDDING_MODEL, }, headers={ "Content-Type": _JSON_CONTENT_TYPE, @@ -129,7 +130,10 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): async def setup_chatbot_rest_client(app: web.Application) -> None: chatbot_settings = get_plugin_settings(app) - client = ChatbotRestClient(base_url=chatbot_settings.base_url) + client = ChatbotRestClient( + base_url=chatbot_settings.base_url, + chatbot_settings=chatbot_settings, + ) app[_APPKEY] = 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 index 40d34b6af99..6e818bf2f76 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/settings.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/settings.py @@ -10,6 +10,8 @@ class ChatbotSettings(BaseCustomSettings, MixinServiceSettings): CHATBOT_HOST: str CHATBOT_PORT: int + CHATBOT_LLM_MODEL: str = "gpt-3.5-turbo" + CHATBOT_EMBEDDING_MODEL: str = "openai/text-embedding-3-large" @cached_property def base_url(self) -> str: From 910f649f415e6b79cf76bf4e262434b40fdc29d3 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 09:30:45 +0200 Subject: [PATCH 05/14] modify docker compose --- .env-devel | 1 + services/docker-compose.yml | 4 ++++ .../application_settings.py | 13 ++++++------- 3 files changed, 11 insertions(+), 7 deletions(-) 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_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index 3c7e38409da..44b3886712a 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -207,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( @@ -261,13 +267,6 @@ class ApplicationSettings(BaseApplicationSettings, MixinLoggingSettings): ), ] - WEBSERVER_CHATBOT: Annotated[ - ChatbotSettings | None, - Field( - json_schema_extra={"auto_default_from_env": True}, - ), - ] - WEBSERVER_GARBAGE_COLLECTOR: Annotated[ GarbageCollectorSettings | None, Field( From e18dcb9c1b6b767114b2854a7442a40e79d2601b Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 09:42:29 +0200 Subject: [PATCH 06/14] review @GitHK --- .../src/simcore_service_webserver/chatbot/_client.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py index 56559ef9a1a..f66a120ac28 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_client.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -61,14 +61,13 @@ def _chatbot_retry(): class ChatbotRestClient: - def __init__(self, base_url: str, chatbot_settings: ChatbotSettings) -> None: + def __init__(self, chatbot_settings: ChatbotSettings) -> None: self._client = httpx.AsyncClient() - self._base_url = base_url self._chatbot_settings = chatbot_settings async def get_settings(self) -> dict[str, Any]: """Fetches chatbot settings""" - url = urljoin(f"{self._base_url}", "/v1/chat/settings") + url = urljoin(f"{self._chatbot_settings.base_url}", "/v1/chat/settings") @_chatbot_retry() async def _request() -> httpx.Response: @@ -87,7 +86,7 @@ async def _request() -> httpx.Response: async def ask_question(self, question: str) -> ChatResponse: """Asks a question to the chatbot""" - url = urljoin(f"{self._base_url}", "/v1/chat") + url = urljoin(f"{self._chatbot_settings.base_url}", "/v1/chat") @_chatbot_retry() async def _request() -> httpx.Response: @@ -131,7 +130,6 @@ async def setup_chatbot_rest_client(app: web.Application) -> None: chatbot_settings = get_plugin_settings(app) client = ChatbotRestClient( - base_url=chatbot_settings.base_url, chatbot_settings=chatbot_settings, ) From fdad989b5fadf9365a8f9c18e556c2fae60c2a78 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 10:06:19 +0200 Subject: [PATCH 07/14] review @pcrespov --- .../chatbot/_client.py | 17 ++++++++++++----- .../chatbot/settings.py | 6 +++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py index f66a120ac28..872d58672f4 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_client.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -4,7 +4,7 @@ """ import logging -from typing import Any, Final +from typing import Annotated, Any, Final from urllib.parse import urljoin import httpx @@ -28,7 +28,7 @@ class ChatResponse(BaseModel): - answer: str = Field(description="Answer from the chatbot") + answer: Annotated[str, Field(description="Answer from the chatbot")] def _should_retry(response: httpx.Response | None) -> bool: @@ -76,8 +76,7 @@ async def _request() -> httpx.Response: try: response = await _request() response.raise_for_status() - response_data: dict[str, Any] = response.json() - return response_data + return response.json() except Exception: _logger.error( # noqa: TRY400 "Failed to fetch chatbot settings from %s", url @@ -86,7 +85,7 @@ async def _request() -> httpx.Response: async def ask_question(self, question: str) -> ChatResponse: """Asks a question to the chatbot""" - url = urljoin(f"{self._chatbot_settings.base_url}", "/v1/chat") + url = urljoin(self._chatbot_settings.base_url, "/v1/chat") @_chatbot_retry() async def _request() -> httpx.Response: @@ -135,6 +134,14 @@ async def setup_chatbot_rest_client(app: web.Application) -> None: 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() # noqa: SLF001 + + app.on_cleanup.append(cleanup_chatbot_client) + def get_chatbot_rest_client(app: web.Application) -> ChatbotRestClient: app_key: ChatbotRestClient = app[_APPKEY] diff --git a/services/web/server/src/simcore_service_webserver/chatbot/settings.py b/services/web/server/src/simcore_service_webserver/chatbot/settings.py index 6e818bf2f76..1b6c3e6ad10 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/settings.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/settings.py @@ -1,6 +1,8 @@ from functools import cached_property from aiohttp import web +from models_library.basic_types import PortInt +from pydantic import ConfigDict from settings_library.base import BaseCustomSettings from settings_library.utils_service import MixinServiceSettings, URLPart @@ -8,8 +10,10 @@ class ChatbotSettings(BaseCustomSettings, MixinServiceSettings): + model_config = ConfigDict(str_strip_whitespace=True, str_min_length=1) + CHATBOT_HOST: str - CHATBOT_PORT: int + CHATBOT_PORT: PortInt CHATBOT_LLM_MODEL: str = "gpt-3.5-turbo" CHATBOT_EMBEDDING_MODEL: str = "openai/text-embedding-3-large" From 0cbd54e6905dd4fd5a997742e0aa0afc45c5db64 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 10:21:59 +0200 Subject: [PATCH 08/14] review @pcrespov --- .../src/simcore_service_webserver/chatbot/__init__.py | 7 ------- .../src/simcore_service_webserver/chatbot/_client.py | 11 +++-------- .../chatbot/chatbot_service.py | 7 +++++++ 3 files changed, 10 insertions(+), 15 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/chatbot/chatbot_service.py diff --git a/services/web/server/src/simcore_service_webserver/chatbot/__init__.py b/services/web/server/src/simcore_service_webserver/chatbot/__init__.py index a51f5bd24b6..e69de29bb2d 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/__init__.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/__init__.py @@ -1,7 +0,0 @@ -# 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/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py index 872d58672f4..c684b67db7a 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_client.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -1,11 +1,5 @@ -"""Interface to communicate with Fogbugz API - -- Simple client to create cases in Fogbugz -""" - import logging from typing import Annotated, Any, Final -from urllib.parse import urljoin import httpx from aiohttp import web @@ -67,7 +61,7 @@ def __init__(self, chatbot_settings: ChatbotSettings) -> None: async def get_settings(self) -> dict[str, Any]: """Fetches chatbot settings""" - url = urljoin(f"{self._chatbot_settings.base_url}", "/v1/chat/settings") + url = httpx.URL(self._chatbot_settings.base_url).join("/v1/chat/settings") @_chatbot_retry() async def _request() -> httpx.Response: @@ -85,7 +79,7 @@ async def _request() -> httpx.Response: async def ask_question(self, question: str) -> ChatResponse: """Asks a question to the chatbot""" - url = urljoin(self._chatbot_settings.base_url, "/v1/chat") + url = httpx.URL(self._chatbot_settings.base_url).join("/v1/chat") @_chatbot_retry() async def _request() -> httpx.Response: @@ -146,3 +140,4 @@ async def cleanup_chatbot_client(app: web.Application) -> None: def get_chatbot_rest_client(app: web.Application) -> ChatbotRestClient: app_key: ChatbotRestClient = app[_APPKEY] return app_key + 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", +] From a8bb6670c5bf2ac65709cb3ecb3d826233b64b72 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 10:22:48 +0200 Subject: [PATCH 09/14] review @pcrespov --- .../web/server/src/simcore_service_webserver/chatbot/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/plugin.py b/services/web/server/src/simcore_service_webserver/chatbot/plugin.py index a1a6968874d..c8c24006ddb 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/plugin.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/plugin.py @@ -1,5 +1,3 @@ -"""tags management subsystem""" - import logging from aiohttp import web From 7397e6e1d22f46c53e1fdf2b5230fae8376fae21 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 10:25:07 +0200 Subject: [PATCH 10/14] fix --- .../web/server/src/simcore_service_webserver/chatbot/_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py index c684b67db7a..9622c9e79b3 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_client.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -140,4 +140,3 @@ async def cleanup_chatbot_client(app: web.Application) -> None: def get_chatbot_rest_client(app: web.Application) -> ChatbotRestClient: app_key: ChatbotRestClient = app[_APPKEY] return app_key - return app_key From fa97066b8f95deecaa356f595a9b79e13d8103f0 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 10:50:02 +0200 Subject: [PATCH 11/14] fix --- .../server/src/simcore_service_webserver/chatbot/_client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py index 9622c9e79b3..904a22708ce 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_client.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -70,7 +70,8 @@ async def _request() -> httpx.Response: try: response = await _request() response.raise_for_status() - return response.json() + response_data: dict[str, Any] = response.json() + return response_data except Exception: _logger.error( # noqa: TRY400 "Failed to fetch chatbot settings from %s", url @@ -132,7 +133,7 @@ async def setup_chatbot_rest_client(app: web.Application) -> None: async def cleanup_chatbot_client(app: web.Application) -> None: client = app.get(_APPKEY) if client: - await client._client.aclose() # noqa: SLF001 + await client._client.aclose() # pylint: disable=protected-access # noqa: SLF001 app.on_cleanup.append(cleanup_chatbot_client) From 3bbc3f9e8452e719592a53473ea41e32fb1eba4f Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 10:55:20 +0200 Subject: [PATCH 12/14] fix --- .../server/src/simcore_service_webserver/chatbot/settings.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/settings.py b/services/web/server/src/simcore_service_webserver/chatbot/settings.py index 1b6c3e6ad10..ad257207431 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/settings.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/settings.py @@ -2,15 +2,14 @@ from aiohttp import web from models_library.basic_types import PortInt -from pydantic import ConfigDict -from settings_library.base import BaseCustomSettings +from settings_library.base import BaseCustomSettings, SettingsConfigDict from settings_library.utils_service import MixinServiceSettings, URLPart from ..application_keys import APP_SETTINGS_APPKEY class ChatbotSettings(BaseCustomSettings, MixinServiceSettings): - model_config = ConfigDict(str_strip_whitespace=True, str_min_length=1) + model_config = SettingsConfigDict(str_strip_whitespace=True, str_min_length=1) CHATBOT_HOST: str CHATBOT_PORT: PortInt From 6f6e332ca749075584e04d86c580e174cfedbd68 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 10:58:39 +0200 Subject: [PATCH 13/14] fix --- .../server/src/simcore_service_webserver/chatbot/settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/settings.py b/services/web/server/src/simcore_service_webserver/chatbot/settings.py index ad257207431..e287f216157 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/settings.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/settings.py @@ -2,7 +2,8 @@ from aiohttp import web from models_library.basic_types import PortInt -from settings_library.base import BaseCustomSettings, SettingsConfigDict +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 From 82740f3b4e4832bb10f6f75ec8ba7f1e66b0bcd6 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 15 Oct 2025 14:05:40 +0200 Subject: [PATCH 14/14] review @pcrespov --- .../chatbot/_client.py | 47 +++++++++---------- .../unit/with_dbs/04/test_chatbot_client.py | 2 - 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_client.py b/services/web/server/src/simcore_service_webserver/chatbot/_client.py index 904a22708ce..89eaeb55874 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_client.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_client.py @@ -5,6 +5,7 @@ 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, @@ -18,9 +19,6 @@ _logger = logging.getLogger(__name__) -_JSON_CONTENT_TYPE = "application/json" - - class ChatResponse(BaseModel): answer: Annotated[str, Field(description="Answer from the chatbot")] @@ -34,24 +32,22 @@ def _should_retry(response: httpx.Response | None) -> bool: ) -def _chatbot_retry(): - """Retry configuration for chatbot API calls""" - return retry( - retry=( - retry_if_result(_should_retry) - | retry_if_exception_type( - ( - httpx.ConnectError, - httpx.TimeoutException, - httpx.NetworkError, - httpx.ProtocolError, - ) +_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, - ) + ) + ), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + reraise=True, +) class ChatbotRestClient: @@ -63,7 +59,7 @@ 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() + @_CHATBOT_RETRY async def _request() -> httpx.Response: return await self._client.get(url) @@ -82,7 +78,7 @@ 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() + @_CHATBOT_RETRY async def _request() -> httpx.Response: return await self._client.post( url, @@ -92,16 +88,15 @@ async def _request() -> httpx.Response: "embedding_model": self._chatbot_settings.CHATBOT_EMBEDDING_MODEL, }, headers={ - "Content-Type": _JSON_CONTENT_TYPE, - "Accept": _JSON_CONTENT_TYPE, + "Content-Type": MIMETYPE_APPLICATION_JSON, + "Accept": MIMETYPE_APPLICATION_JSON, }, ) try: response = await _request() response.raise_for_status() - response_data: dict[str, Any] = response.json() - return ChatResponse(**response_data) + return ChatResponse.model_validate(response.json()) except Exception: _logger.error( # noqa: TRY400 "Failed to ask question to chatbot at %s", url 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 index 4692dc9918f..365ef932f6e 100644 --- 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 @@ -10,7 +10,6 @@ import pytest import respx from aiohttp.test_utils import TestClient -from pytest_mock import MockerFixture 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 ( @@ -24,7 +23,6 @@ def app_environment( monkeypatch: pytest.MonkeyPatch, app_environment: EnvVarsDict, - mocker: MockerFixture, ): return app_environment | setenvs_from_dict( monkeypatch,