Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env-devel
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ DYNAMIC_SCHEDULER_UI_STORAGE_SECRET=adminadmin

FUNCTION_SERVICES_AUTHORS='{"UN": {"name": "Unknown", "email": "[email protected]", "affiliation": "unknown"}}'

WEBSERVER_CHATBOT={}
WEBSERVER_LICENSES={}
WEBSERVER_FOGBUGZ={}
LICENSES_ITIS_VIP_SYNCER_ENABLED=false
Expand Down
4 changes: 4 additions & 0 deletions services/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Empty file.
138 changes: 138 additions & 0 deletions services/web/server/src/simcore_service_webserver/chatbot/_client.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# mypy: disable-error-code=truthy-function
from ._client import ChatbotRestClient, get_chatbot_rest_client

__all__ = [
"get_chatbot_rest_client",
"ChatbotRestClient",
]
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Original file line number Diff line number Diff line change
@@ -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"
Loading