Skip to content

Commit 3d5e1bd

Browse files
🎨 Update chatbot client (#8664)
1 parent c2ad882 commit 3d5e1bd

File tree

7 files changed

+277
-63
lines changed

7 files changed

+277
-63
lines changed

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

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,44 @@
11
import logging
2-
from typing import Annotated, Any, Final
2+
from typing import Annotated, Any, Final, Literal
33

44
import httpx
55
from aiohttp import web
6-
from pydantic import BaseModel, Field
6+
from pydantic import BaseModel, Field, model_validator
77
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
88

9+
from .exceptions import NoResponseFromChatbotError
910
from .settings import ChatbotSettings, get_plugin_settings
1011

1112
_logger = logging.getLogger(__name__)
1213

1314

15+
class ResponseMessage(BaseModel):
16+
content: str
17+
18+
19+
class ResponseItem(BaseModel):
20+
index: int # 0-based index of the response
21+
message: ResponseMessage
22+
23+
1424
class ChatResponse(BaseModel):
15-
answer: Annotated[str, Field(description="Answer from the chatbot")]
25+
id: str # unique identifier for the chat response
26+
choices: Annotated[list[ResponseItem], Field(description="Answer from the chatbot")]
27+
28+
29+
class Message(BaseModel):
30+
role: Literal["user", "assistant", "developer"]
31+
content: Annotated[str, Field(description="Content of the message")]
32+
name: Annotated[
33+
str | None, Field(description="Optional name of the message sender")
34+
] = None
35+
36+
@model_validator(mode="after")
37+
def check_name_requires_user_role(self) -> "Message":
38+
if self.name is not None and self.role != "user":
39+
msg = "Currently the chatbot only supports name for the user role"
40+
raise ValueError(msg)
41+
return self
1642

1743

1844
class ChatbotRestClient:
@@ -38,17 +64,19 @@ async def _request() -> httpx.Response:
3864
)
3965
raise
4066

41-
async def ask_question(self, question: str) -> ChatResponse:
42-
"""Asks a question to the chatbot"""
43-
url = httpx.URL(self._chatbot_settings.base_url).join("/v1/chat")
67+
async def send(self, messages: list[Message]) -> ResponseMessage:
68+
"""Send a list of messages to the chatbot and returns the chatbot's response message."""
69+
url = httpx.URL(self._chatbot_settings.base_url).join("/v1/chat/completions")
4470

4571
async def _request() -> httpx.Response:
4672
return await self._client.post(
4773
url,
4874
json={
49-
"question": question,
50-
"llm": self._chatbot_settings.CHATBOT_LLM_MODEL,
51-
"embedding_model": self._chatbot_settings.CHATBOT_EMBEDDING_MODEL,
75+
"messages": [
76+
msg.model_dump(mode="json", exclude_none=True)
77+
for msg in messages
78+
],
79+
"model": self._chatbot_settings.CHATBOT_MODEL,
5280
},
5381
headers={
5482
"Content-Type": MIMETYPE_APPLICATION_JSON,
@@ -60,7 +88,11 @@ async def _request() -> httpx.Response:
6088
try:
6189
response = await _request()
6290
response.raise_for_status()
63-
return ChatResponse.model_validate(response.json())
91+
chat_response = ChatResponse.model_validate(response.json())
92+
if len(chat_response.choices) == 0:
93+
raise NoResponseFromChatbotError(chat_completion_id=chat_response.id)
94+
return chat_response.choices[0].message
95+
6496
except Exception:
6597
_logger.error( # noqa: TRY400
6698
"Failed to ask question to chatbot at %s", url

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

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import functools
22
import logging
33
from collections.abc import AsyncIterator
4-
from typing import Final
4+
from typing import Final, Literal, NamedTuple
55

66
from aiohttp import web
77
from models_library.basic_types import IDStr
88
from models_library.conversations import ConversationMessageType, ConversationUserType
9+
from models_library.groups import GroupID
910
from models_library.rabbitmq_messages import WebserverChatbotRabbitMessage
1011
from models_library.rest_ordering import OrderBy, OrderDirection
1112
from pydantic import TypeAdapter
12-
from servicelib.logging_utils import log_context
13+
from servicelib.logging_utils import log_catch, log_context, log_decorator
1314
from servicelib.rabbitmq import RabbitMQClient
1415

1516
from ..conversations import conversations_service
1617
from ..conversations.errors import ConversationErrorNotFoundError
18+
from ..groups.api import list_group_members
1719
from ..products import products_service
1820
from ..rabbitmq import get_rabbitmq_client
21+
from ..users import users_service
22+
from ._client import Message
1923
from .chatbot_service import get_chatbot_rest_client
2024

2125
_logger = logging.getLogger(__name__)
@@ -28,60 +32,113 @@
2832
_CHATBOT_PROCESS_MESSAGE_TTL_IN_MS = 2 * 60 * 60 * 1000 # 2 hours
2933

3034

35+
class _Role(NamedTuple):
36+
role: Literal["user", "assistant", "developer"]
37+
name: str | None = None
38+
39+
40+
_SUPPORT_ROLE_NAME: Final[str] = "support-team-member"
41+
42+
_CHATBOT_INSTRUCTION_MESSAGE: Final[
43+
str
44+
] = """
45+
This conversation takes place in the context of the {product} product. Only answer questions related to this product.
46+
The user '{support_role_name}' is a support team member and is assisting users of the {product} product
47+
with their inquiries. Help the user by providing answers to their questions. Make your answers concise and to the point.
48+
Address users by their name. Be friendly and accommodating.
49+
"""
50+
51+
52+
async def _get_role(
53+
*,
54+
app: web.Application,
55+
message_gid: GroupID,
56+
chatbot_primary_gid: GroupID,
57+
support_group_primary_gids: set[GroupID],
58+
) -> _Role:
59+
if message_gid == chatbot_primary_gid:
60+
return _Role(role="assistant")
61+
if message_gid in support_group_primary_gids:
62+
return _Role(role="user", name=_SUPPORT_ROLE_NAME)
63+
user_id = await users_service.get_user_id_from_gid(app=app, primary_gid=message_gid)
64+
user_full_name = await users_service.get_user_fullname(app=app, user_id=user_id)
65+
return _Role(role="user", name=user_full_name["first_name"])
66+
67+
68+
@log_decorator(_logger, logging.DEBUG)
3169
async def _process_chatbot_trigger_message(app: web.Application, data: bytes) -> bool:
3270
rabbit_message = TypeAdapter(WebserverChatbotRabbitMessage).validate_json(data)
3371
assert app # nosec
3472

35-
with log_context(
36-
_logger,
37-
logging.DEBUG,
38-
msg=f"Processing chatbot trigger message for conversation ID {rabbit_message.conversation.conversation_id}",
39-
):
40-
_product_name = rabbit_message.conversation.product_name
41-
_product = products_service.get_product(app, product_name=_product_name)
73+
with log_catch(logger=_logger, reraise=False):
74+
product_name = rabbit_message.conversation.product_name
75+
product = products_service.get_product(app, product_name=product_name)
4276

43-
if _product.support_chatbot_user_id is None:
77+
if product.support_chatbot_user_id is None:
4478
_logger.error(
4579
"Product %s does not have support_chatbot_user_id configured, cannot process chatbot message. (This should not happen)",
46-
_product_name,
80+
product_name,
4781
)
4882
return True # return true to avoid re-processing
83+
support_group_primary_gids = set()
84+
if product.support_standard_group_id is not None:
85+
support_group_primary_gids = {
86+
elm.primary_gid
87+
for elm in await list_group_members(
88+
app, product.support_standard_group_id
89+
)
90+
}
91+
92+
chatbot_primary_gid = await users_service.get_user_primary_group_id(
93+
app=app, user_id=product.support_chatbot_user_id
94+
)
4995

5096
# Get last 20 messages for the conversation ID
5197
with log_context(
5298
_logger,
5399
logging.DEBUG,
54100
msg=f"Listed messages for conversation ID {rabbit_message.conversation.conversation_id}",
55101
):
56-
_, messages = await conversations_service.list_messages_for_conversation(
57-
app=app,
58-
conversation_id=rabbit_message.conversation.conversation_id,
59-
offset=0,
60-
limit=20,
61-
order_by=OrderBy(field=IDStr("created"), direction=OrderDirection.DESC),
102+
_, messages_in_db = (
103+
await conversations_service.list_messages_for_conversation(
104+
app=app,
105+
conversation_id=rabbit_message.conversation.conversation_id,
106+
offset=0,
107+
limit=20,
108+
order_by=OrderBy(
109+
field=IDStr("created"), direction=OrderDirection.DESC
110+
),
111+
)
62112
)
63113

64-
_question_for_chatbot = ""
65-
for inx, msg in enumerate(messages):
66-
if inx == 0:
67-
# Make last message stand out as the question
68-
_question_for_chatbot += (
69-
"User last message: \n"
70-
f"{msg.content.strip()} \n\n"
71-
"Previous messages in the conversation: \n"
72-
)
73-
else:
74-
_question_for_chatbot += f"{msg.content.strip()}\n"
114+
messages = []
115+
for msg in messages_in_db:
116+
role = await _get_role(
117+
app=app,
118+
message_gid=msg.user_group_id,
119+
chatbot_primary_gid=chatbot_primary_gid,
120+
support_group_primary_gids=support_group_primary_gids,
121+
)
122+
messages.append(
123+
Message(role=role.role, name=role.name, content=msg.content)
124+
)
125+
context_message = Message(
126+
role="developer",
127+
content=_CHATBOT_INSTRUCTION_MESSAGE.format(
128+
product=product_name,
129+
support_role_name=_SUPPORT_ROLE_NAME,
130+
),
131+
)
132+
messages.append(context_message)
75133

76134
# Talk to the chatbot service
77-
78135
with log_context(
79136
_logger,
80137
logging.DEBUG,
81138
msg=f"Asking question from chatbot conversation ID {rabbit_message.conversation.conversation_id}",
82139
):
83140
chatbot_client = get_chatbot_rest_client(app)
84-
chat_response = await chatbot_client.ask_question(_question_for_chatbot)
141+
response_message = await chatbot_client.send(messages)
85142

86143
try:
87144
with log_context(
@@ -92,10 +149,10 @@ async def _process_chatbot_trigger_message(app: web.Application, data: bytes) ->
92149
await conversations_service.create_support_message(
93150
app=app,
94151
product_name=rabbit_message.conversation.product_name,
95-
user_id=_product.support_chatbot_user_id,
152+
user_id=product.support_chatbot_user_id,
96153
conversation_user_type=ConversationUserType.CHATBOT_USER,
97154
conversation=rabbit_message.conversation,
98-
content=chat_response.answer,
155+
content=response_message.content,
99156
type_=ConversationMessageType.MESSAGE,
100157
)
101158
except ConversationErrorNotFoundError:
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from common_library.errors_classes import OsparcErrorMixin
2+
3+
4+
class BaseChatbotException(OsparcErrorMixin, Exception):
5+
"""Base exception for chatbot errors"""
6+
7+
8+
class NoResponseFromChatbotError(BaseChatbotException):
9+
msg_template = (
10+
"No response received from chatbot for chat completion {chat_completion_id}"
11+
)

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ class ChatbotSettings(BaseCustomSettings, MixinServiceSettings):
1414

1515
CHATBOT_HOST: str
1616
CHATBOT_PORT: PortInt
17-
CHATBOT_LLM_MODEL: str = "gpt-3.5-turbo"
18-
CHATBOT_EMBEDDING_MODEL: str = "openai/text-embedding-3-large"
17+
CHATBOT_MODEL: str = "gpt-4o-mini"
1918

2019
@cached_property
2120
def base_url(self) -> str:

services/web/server/tests/unit/with_dbs/04/chatbot/conftest.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@
44
# pylint: disable=too-many-arguments
55
# pylint: disable=too-many-statements
66

7-
from collections.abc import Iterator
7+
from collections.abc import AsyncIterator, Iterator
88

99
import httpx
1010
import pytest
1111
import respx
12+
from aiohttp.test_utils import TestClient
13+
from faker import Faker
1214
from pytest_mock import MockerFixture, MockType
1315
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
1416
from pytest_simcore.helpers.typing_env import EnvVarsDict
17+
from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict
18+
from simcore_service_webserver.chatbot._client import (
19+
ChatResponse,
20+
ResponseItem,
21+
ResponseMessage,
22+
)
1523
from simcore_service_webserver.products import products_service
1624

1725

@@ -30,29 +38,56 @@ def app_environment(
3038

3139

3240
@pytest.fixture
33-
def mocked_chatbot_api() -> Iterator[respx.MockRouter]:
41+
def mocked_chatbot_api(faker: Faker) -> Iterator[respx.MockRouter]:
3442
_BASE_URL = "http://chatbot:8000"
3543

3644
# Define responses in the order they will be called during the test
3745
chatbot_answer_responses = [
38-
{"answer": "42"},
46+
ChatResponse(
47+
id=f"{faker.uuid4()}",
48+
choices=[ResponseItem(index=0, message=ResponseMessage(content="42"))],
49+
)
3950
]
4051

4152
with respx.mock(base_url=_BASE_URL) as mock:
4253
# Create a side_effect that returns responses in sequence
43-
mock.post(path="/v1/chat").mock(
54+
mock.post(path="/v1/chat/completions").mock(
4455
side_effect=[
45-
httpx.Response(200, json=response)
56+
httpx.Response(200, json=response.model_dump(mode="json"))
4657
for response in chatbot_answer_responses
4758
]
4859
)
4960
yield mock
5061

5162

5263
@pytest.fixture
53-
def mocked_get_current_product(mocker: MockerFixture) -> MockType:
64+
async def chatbot_user(client: TestClient) -> AsyncIterator[UserInfoDict]:
65+
async with NewUser(
66+
user_data={
67+
"name": "chatbot user",
68+
},
69+
app=client.app,
70+
) as user_info:
71+
yield user_info
72+
73+
74+
@pytest.fixture
75+
async def support_team_user(client: TestClient) -> AsyncIterator[UserInfoDict]:
76+
async with NewUser(
77+
user_data={
78+
"name": "support team user",
79+
},
80+
app=client.app,
81+
) as user_info:
82+
yield user_info
83+
84+
85+
@pytest.fixture
86+
def mocked_get_current_product(
87+
chatbot_user: UserInfoDict, mocker: MockerFixture
88+
) -> MockType:
5489
mock = mocker.patch.object(products_service, "get_product")
5590
mocked_product = mocker.Mock()
56-
mocked_product.support_chatbot_user_id = 123
91+
mocked_product.support_chatbot_user_id = chatbot_user["id"]
5792
mock.return_value = mocked_product
5893
return mock

0 commit comments

Comments
 (0)