Skip to content

Commit 6213a3d

Browse files
✨ Introduce chatbot client (⚠️) (#8516)
1 parent 20160d7 commit 6213a3d

File tree

11 files changed

+285
-2
lines changed

11 files changed

+285
-2
lines changed

.env-devel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ DYNAMIC_SCHEDULER_UI_STORAGE_SECRET=adminadmin
144144

145145
FUNCTION_SERVICES_AUTHORS='{"UN": {"name": "Unknown", "email": "unknown@osparc.io", "affiliation": "unknown"}}'
146146

147+
WEBSERVER_CHATBOT={}
147148
WEBSERVER_LICENSES={}
148149
WEBSERVER_FOGBUGZ={}
149150
LICENSES_ITIS_VIP_SYNCER_ENABLED=false

services/docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,7 @@ services:
713713
PROMETHEUS_URL: ${WEBSERVER_PROMETHEUS_URL}
714714

715715
WEBSERVER_CATALOG: ${WEBSERVER_CATALOG}
716+
WEBSERVER_CHATBOT: ${WEBSERVER_CHATBOT}
716717

717718
# WEBSERVER_CREDIT_COMPUTATION
718719
WEBSERVER_CREDIT_COMPUTATION_ENABLED: ${WEBSERVER_CREDIT_COMPUTATION_ENABLED}
@@ -923,6 +924,7 @@ services:
923924
WEBSERVER_ACTIVITY: ${WB_DB_EL_ACTIVITY}
924925
WEBSERVER_ANNOUNCEMENTS: ${WB_DB_EL_ANNOUNCEMENTS}
925926
WEBSERVER_CATALOG: ${WB_DB_EL_CATALOG}
927+
WEBSERVER_CHATBOT: "null"
926928
WEBSERVER_CELERY: "null"
927929
WEBSERVER_DB_LISTENER: ${WB_DB_EL_DB_LISTENER}
928930
WEBSERVER_DIAGNOSTICS: ${WB_DB_EL_DIAGNOSTICS}
@@ -1006,6 +1008,7 @@ services:
10061008
WEBSERVER_ACTIVITY: ${WB_GC_ACTIVITY}
10071009
WEBSERVER_ANNOUNCEMENTS: ${WB_GC_ANNOUNCEMENTS}
10081010
WEBSERVER_CATALOG: ${WB_GC_CATALOG}
1011+
WEBSERVER_CHATBOT: "null"
10091012
WEBSERVER_CELERY: "null"
10101013
WEBSERVER_DB_LISTENER: ${WB_GC_DB_LISTENER}
10111014
WEBSERVER_DIAGNOSTICS: ${WB_GC_DIAGNOSTICS}
@@ -1082,6 +1085,7 @@ services:
10821085
WEBSERVER_ACTIVITY: "null"
10831086
WEBSERVER_ANNOUNCEMENTS: 0
10841087
WEBSERVER_CATALOG: "null"
1088+
WEBSERVER_CHATBOT: "null"
10851089
WEBSERVER_CELERY: "null"
10861090
WEBSERVER_DB_LISTENER: 0
10871091
WEBSERVER_DIRECTOR_V2: "null"

services/web/server/src/simcore_service_webserver/application.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
from .dynamic_scheduler.plugin import setup_dynamic_scheduler
3737
from .email.plugin import setup_email
3838
from .exporter.plugin import setup_exporter
39-
from .fogbugz.plugin import setup_fogbugz
4039
from .folders.plugin import setup_folders
4140
from .functions.plugin import setup_functions
4241
from .garbage_collector.plugin import setup_garbage_collector
@@ -175,7 +174,6 @@ def create_application(tracing_config: TracingConfig) -> web.Application:
175174
setup_projects(app)
176175

177176
# conversations
178-
setup_fogbugz(app) # Needed for support conversations
179177
setup_conversations(app)
180178

181179
# licenses

services/web/server/src/simcore_service_webserver/application_settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ._meta import API_VERSION, API_VTAG, APP_NAME
3333
from .application_keys import APP_SETTINGS_APPKEY
3434
from .catalog.settings import CatalogSettings
35+
from .chatbot.settings import ChatbotSettings
3536
from .collaboration.settings import RealTimeCollaborationSettings
3637
from .diagnostics.settings import DiagnosticsSettings
3738
from .director_v2.settings import DirectorV2Settings
@@ -206,6 +207,12 @@ class ApplicationSettings(BaseApplicationSettings, MixinLoggingSettings):
206207
description="catalog service client's plugin",
207208
),
208209
]
210+
WEBSERVER_CHATBOT: Annotated[
211+
ChatbotSettings | None,
212+
Field(
213+
json_schema_extra={"auto_default_from_env": True},
214+
),
215+
]
209216
WEBSERVER_CELERY: Annotated[
210217
CelerySettings | None,
211218
Field(

services/web/server/src/simcore_service_webserver/chatbot/__init__.py

Whitespace-only changes.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import logging
2+
from typing import Annotated, Any, Final
3+
4+
import httpx
5+
from aiohttp import web
6+
from pydantic import BaseModel, Field
7+
from servicelib.aiohttp import status
8+
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
9+
from tenacity import (
10+
retry,
11+
retry_if_exception_type,
12+
retry_if_result,
13+
stop_after_attempt,
14+
wait_exponential,
15+
)
16+
17+
from .settings import ChatbotSettings, get_plugin_settings
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
22+
class ChatResponse(BaseModel):
23+
answer: Annotated[str, Field(description="Answer from the chatbot")]
24+
25+
26+
def _should_retry(response: httpx.Response | None) -> bool:
27+
if response is None:
28+
return True
29+
return (
30+
response.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR
31+
or response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
32+
)
33+
34+
35+
_CHATBOT_RETRY = retry(
36+
retry=(
37+
retry_if_result(_should_retry)
38+
| retry_if_exception_type(
39+
(
40+
httpx.ConnectError,
41+
httpx.TimeoutException,
42+
httpx.NetworkError,
43+
httpx.ProtocolError,
44+
)
45+
)
46+
),
47+
stop=stop_after_attempt(3),
48+
wait=wait_exponential(multiplier=1, min=1, max=10),
49+
reraise=True,
50+
)
51+
52+
53+
class ChatbotRestClient:
54+
def __init__(self, chatbot_settings: ChatbotSettings) -> None:
55+
self._client = httpx.AsyncClient()
56+
self._chatbot_settings = chatbot_settings
57+
58+
async def get_settings(self) -> dict[str, Any]:
59+
"""Fetches chatbot settings"""
60+
url = httpx.URL(self._chatbot_settings.base_url).join("/v1/chat/settings")
61+
62+
@_CHATBOT_RETRY
63+
async def _request() -> httpx.Response:
64+
return await self._client.get(url)
65+
66+
try:
67+
response = await _request()
68+
response.raise_for_status()
69+
response_data: dict[str, Any] = response.json()
70+
return response_data
71+
except Exception:
72+
_logger.error( # noqa: TRY400
73+
"Failed to fetch chatbot settings from %s", url
74+
)
75+
raise
76+
77+
async def ask_question(self, question: str) -> ChatResponse:
78+
"""Asks a question to the chatbot"""
79+
url = httpx.URL(self._chatbot_settings.base_url).join("/v1/chat")
80+
81+
@_CHATBOT_RETRY
82+
async def _request() -> httpx.Response:
83+
return await self._client.post(
84+
url,
85+
json={
86+
"question": question,
87+
"llm": self._chatbot_settings.CHATBOT_LLM_MODEL,
88+
"embedding_model": self._chatbot_settings.CHATBOT_EMBEDDING_MODEL,
89+
},
90+
headers={
91+
"Content-Type": MIMETYPE_APPLICATION_JSON,
92+
"Accept": MIMETYPE_APPLICATION_JSON,
93+
},
94+
)
95+
96+
try:
97+
response = await _request()
98+
response.raise_for_status()
99+
return ChatResponse.model_validate(response.json())
100+
except Exception:
101+
_logger.error( # noqa: TRY400
102+
"Failed to ask question to chatbot at %s", url
103+
)
104+
raise
105+
106+
async def __aenter__(self):
107+
"""Async context manager entry"""
108+
return self
109+
110+
async def __aexit__(self, exc_type, exc_val, exc_tb):
111+
"""Async context manager exit - cleanup client"""
112+
await self._client.aclose()
113+
114+
115+
_APPKEY: Final = web.AppKey(ChatbotRestClient.__name__, ChatbotRestClient)
116+
117+
118+
async def setup_chatbot_rest_client(app: web.Application) -> None:
119+
chatbot_settings = get_plugin_settings(app)
120+
121+
client = ChatbotRestClient(
122+
chatbot_settings=chatbot_settings,
123+
)
124+
125+
app[_APPKEY] = client
126+
127+
# Add cleanup on app shutdown
128+
async def cleanup_chatbot_client(app: web.Application) -> None:
129+
client = app.get(_APPKEY)
130+
if client:
131+
await client._client.aclose() # pylint: disable=protected-access # noqa: SLF001
132+
133+
app.on_cleanup.append(cleanup_chatbot_client)
134+
135+
136+
def get_chatbot_rest_client(app: web.Application) -> ChatbotRestClient:
137+
app_key: ChatbotRestClient = app[_APPKEY]
138+
return app_key
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# mypy: disable-error-code=truthy-function
2+
from ._client import ChatbotRestClient, get_chatbot_rest_client
3+
4+
__all__ = [
5+
"get_chatbot_rest_client",
6+
"ChatbotRestClient",
7+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import logging
2+
3+
from aiohttp import web
4+
5+
from ..application_setup import ModuleCategory, app_setup_func
6+
from ..products.plugin import setup_products
7+
from ._client import setup_chatbot_rest_client
8+
9+
_logger = logging.getLogger(__name__)
10+
11+
12+
@app_setup_func(
13+
__name__,
14+
ModuleCategory.ADDON,
15+
settings_name="WEBSERVER_CHATBOT",
16+
logger=_logger,
17+
)
18+
def setup_chatbot(app: web.Application):
19+
setup_products(app)
20+
app.on_startup.append(setup_chatbot_rest_client)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from functools import cached_property
2+
3+
from aiohttp import web
4+
from models_library.basic_types import PortInt
5+
from pydantic_settings import SettingsConfigDict
6+
from settings_library.base import BaseCustomSettings
7+
from settings_library.utils_service import MixinServiceSettings, URLPart
8+
9+
from ..application_keys import APP_SETTINGS_APPKEY
10+
11+
12+
class ChatbotSettings(BaseCustomSettings, MixinServiceSettings):
13+
model_config = SettingsConfigDict(str_strip_whitespace=True, str_min_length=1)
14+
15+
CHATBOT_HOST: str
16+
CHATBOT_PORT: PortInt
17+
CHATBOT_LLM_MODEL: str = "gpt-3.5-turbo"
18+
CHATBOT_EMBEDDING_MODEL: str = "openai/text-embedding-3-large"
19+
20+
@cached_property
21+
def base_url(self) -> str:
22+
# http://chatbot:8000
23+
return self._compose_url(
24+
prefix="CHATBOT",
25+
port=URLPart.REQUIRED,
26+
vtag=URLPart.EXCLUDE,
27+
)
28+
29+
30+
def get_plugin_settings(app: web.Application) -> ChatbotSettings:
31+
settings = app[APP_SETTINGS_APPKEY].WEBSERVER_CHATBOT
32+
assert settings, "plugin.setup_chatbot not called?" # nosec
33+
assert isinstance(settings, ChatbotSettings) # nosec
34+
return settings

services/web/server/src/simcore_service_webserver/conversations/plugin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from ..application_keys import APP_SETTINGS_APPKEY
88
from ..application_setup import ModuleCategory, app_setup_func
9+
from ..chatbot.plugin import setup_chatbot
910
from ..fogbugz.plugin import setup_fogbugz
1011
from ._controller import _conversations_messages_rest, _conversations_rest
1112

@@ -23,6 +24,7 @@ def setup_conversations(app: web.Application):
2324
assert app[APP_SETTINGS_APPKEY].WEBSERVER_CONVERSATIONS # nosec
2425

2526
setup_fogbugz(app)
27+
setup_chatbot(app)
2628

2729
app.router.add_routes(_conversations_rest.routes)
2830
app.router.add_routes(_conversations_messages_rest.routes)

0 commit comments

Comments
 (0)