Skip to content

Commit 3972828

Browse files
introudce chatbot client skeleton
1 parent 945ca4f commit 3972828

File tree

7 files changed

+158
-35
lines changed

7 files changed

+158
-35
lines changed

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: 8 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
@@ -260,6 +261,13 @@ class ApplicationSettings(BaseApplicationSettings, MixinLoggingSettings):
260261
),
261262
]
262263

264+
WEBSERVER_CHATBOT: Annotated[
265+
ChatbotSettings | None,
266+
Field(
267+
json_schema_extra={"auto_default_from_env": True},
268+
),
269+
]
270+
263271
WEBSERVER_GARBAGE_COLLECTOR: Annotated[
264272
GarbageCollectorSettings | None,
265273
Field(
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
# mypy: disable-error-code=truthy-function
2-
from ._client import ChatbotQuestionCreate, ChatbotRestClient, get_chatbot_rest_client
2+
from ._client import ChatbotRestClient, get_chatbot_rest_client
33

44
__all__ = [
55
"get_chatbot_rest_client",
6-
"ChatbotQuestionCreate",
76
"ChatbotRestClient",
87
]

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

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import httpx
1111
from aiohttp import web
12-
from pydantic import AnyUrl, BaseModel, Field
12+
from pydantic import BaseModel, Field
1313
from servicelib.aiohttp import status
1414
from tenacity import (
1515
retry,
@@ -23,14 +23,12 @@
2323

2424
_logger = logging.getLogger(__name__)
2525

26+
2627
_JSON_CONTENT_TYPE = "application/json"
27-
_UNKNOWN_ERROR_MESSAGE = "Unknown error occurred"
2828

2929

30-
class ChatbotQuestionCreate(BaseModel):
31-
fogbugz_project_id: int = Field(description="Project ID in Fogbugz")
32-
title: str = Field(description="Case title")
33-
description: str = Field(description="Case description/first comment")
30+
class ChatResponse(BaseModel):
31+
answer: str = Field(description="Answer from the chatbot")
3432

3533

3634
def _should_retry(response: httpx.Response | None) -> bool:
@@ -42,33 +40,36 @@ def _should_retry(response: httpx.Response | None) -> bool:
4240
)
4341

4442

43+
def _chatbot_retry():
44+
"""Retry configuration for chatbot API calls"""
45+
return retry(
46+
retry=(
47+
retry_if_result(_should_retry)
48+
| retry_if_exception_type(
49+
(
50+
httpx.ConnectError,
51+
httpx.TimeoutException,
52+
httpx.NetworkError,
53+
httpx.ProtocolError,
54+
)
55+
)
56+
),
57+
stop=stop_after_attempt(3),
58+
wait=wait_exponential(multiplier=1, min=1, max=10),
59+
reraise=True,
60+
)
61+
62+
4563
class ChatbotRestClient:
46-
def __init__(self, host: AnyUrl, port: int) -> None:
64+
def __init__(self, base_url: str) -> None:
4765
self._client = httpx.AsyncClient()
48-
self.host = host
49-
self.port = port
50-
self._base_url = f"{self.host}:{self.port}"
66+
self._base_url = base_url
5167

5268
async def get_settings(self) -> dict[str, Any]:
5369
"""Fetches chatbot settings"""
5470
url = urljoin(f"{self._base_url}", "/v1/chat/settings")
5571

56-
@retry(
57-
retry=(
58-
retry_if_result(_should_retry)
59-
| retry_if_exception_type(
60-
(
61-
httpx.ConnectError,
62-
httpx.TimeoutException,
63-
httpx.NetworkError,
64-
httpx.ProtocolError,
65-
)
66-
)
67-
),
68-
stop=stop_after_attempt(3),
69-
wait=wait_exponential(multiplier=1, min=1, max=10),
70-
reraise=True,
71-
)
72+
@_chatbot_retry()
7273
async def _request() -> httpx.Response:
7374
return await self._client.get(url)
7475

@@ -83,6 +84,36 @@ async def _request() -> httpx.Response:
8384
)
8485
raise
8586

87+
async def ask_question(self, question: str) -> ChatResponse:
88+
"""Asks a question to the chatbot"""
89+
url = urljoin(f"{self._base_url}", "/v1/chat")
90+
91+
@_chatbot_retry()
92+
async def _request() -> httpx.Response:
93+
return await self._client.post(
94+
url,
95+
json={
96+
"question": question,
97+
"llm": "gpt-3.5-turbo",
98+
"embedding_model": "openai/text-embedding-3-large",
99+
},
100+
headers={
101+
"Content-Type": _JSON_CONTENT_TYPE,
102+
"Accept": _JSON_CONTENT_TYPE,
103+
},
104+
)
105+
106+
try:
107+
response = await _request()
108+
response.raise_for_status()
109+
response_data: dict[str, Any] = response.json()
110+
return ChatResponse(**response_data)
111+
except Exception:
112+
_logger.error( # noqa: TRY400
113+
"Failed to ask question to chatbot at %s", url
114+
)
115+
raise
116+
86117
async def __aenter__(self):
87118
"""Async context manager entry"""
88119
return self
@@ -96,9 +127,9 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
96127

97128

98129
async def setup_chatbot_rest_client(app: web.Application) -> None:
99-
settings = get_plugin_settings(app)
130+
chatbot_settings = get_plugin_settings(app)
100131

101-
client = ChatbotRestClient(host=settings.CHATBOT_HOST, port=settings.CHATBOT_PORT)
132+
client = ChatbotRestClient(base_url=chatbot_settings.base_url)
102133

103134
app[_APPKEY] = client
104135

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
1+
from functools import cached_property
2+
13
from aiohttp import web
2-
from pydantic import AnyUrl
34
from settings_library.base import BaseCustomSettings
5+
from settings_library.utils_service import MixinServiceSettings, URLPart
46

57
from ..application_keys import APP_SETTINGS_APPKEY
68

79

8-
class ChatbotSettings(BaseCustomSettings):
9-
CHATBOT_HOST: AnyUrl
10+
class ChatbotSettings(BaseCustomSettings, MixinServiceSettings):
11+
CHATBOT_HOST: str
1012
CHATBOT_PORT: int
1113

14+
@cached_property
15+
def base_url(self) -> str:
16+
# http://chatbot:8000/v1
17+
return self._compose_url(
18+
prefix="CHATBOT",
19+
port=URLPart.REQUIRED,
20+
vtag=URLPart.EXCLUDE,
21+
)
22+
1223

1324
def get_plugin_settings(app: web.Application) -> ChatbotSettings:
1425
settings = app[APP_SETTINGS_APPKEY].WEBSERVER_CHATBOT

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)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
4+
# pylint: disable=too-many-arguments
5+
# pylint: disable=too-many-statements
6+
7+
from collections.abc import Iterator
8+
9+
import httpx
10+
import pytest
11+
import respx
12+
from aiohttp.test_utils import TestClient
13+
from pytest_mock import MockerFixture
14+
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
15+
from pytest_simcore.helpers.typing_env import EnvVarsDict
16+
from simcore_service_webserver.chatbot._client import (
17+
ChatResponse,
18+
get_chatbot_rest_client,
19+
)
20+
from simcore_service_webserver.chatbot.settings import ChatbotSettings
21+
22+
23+
@pytest.fixture
24+
def app_environment(
25+
monkeypatch: pytest.MonkeyPatch,
26+
app_environment: EnvVarsDict,
27+
mocker: MockerFixture,
28+
):
29+
return app_environment | setenvs_from_dict(
30+
monkeypatch,
31+
{
32+
"CHATBOT_HOST": "chatbot",
33+
"CHATBOT_PORT": "8000",
34+
},
35+
)
36+
37+
38+
@pytest.fixture
39+
def mocked_chatbot_api() -> Iterator[respx.MockRouter]:
40+
_BASE_URL = "http://chatbot:8000"
41+
42+
# Define responses in the order they will be called during the test
43+
chatbot_answer_responses = [
44+
{"answer": "42"},
45+
]
46+
47+
with respx.mock(base_url=_BASE_URL) as mock:
48+
# Create a side_effect that returns responses in sequence
49+
mock.post(path="/v1/chat").mock(
50+
side_effect=[
51+
httpx.Response(200, json=response)
52+
for response in chatbot_answer_responses
53+
]
54+
)
55+
yield mock
56+
57+
58+
async def test_chatbot_client(
59+
app_environment: EnvVarsDict,
60+
client: TestClient,
61+
mocked_chatbot_api: respx.MockRouter,
62+
):
63+
assert client.app
64+
65+
settings = ChatbotSettings.create_from_envs()
66+
assert settings.CHATBOT_HOST
67+
assert settings.CHATBOT_PORT
68+
69+
chatbot_client = get_chatbot_rest_client(client.app)
70+
assert chatbot_client
71+
72+
output = await chatbot_client.ask_question("What is the meaning of life?")
73+
assert isinstance(output, ChatResponse)
74+
assert output.answer == "42"

0 commit comments

Comments
 (0)