diff --git a/packages/models-library/src/models_library/api_schemas_webserver/groups.py b/packages/models-library/src/models_library/api_schemas_webserver/groups.py index 8dc0166aaec9..d2191e40d3fe 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/groups.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/groups.py @@ -202,6 +202,12 @@ class MyGroupsGet(OutputSchema): description="Group ID of the app support team or None if no support is defined for this product" ), ] = None + chatbot: Annotated[ + GroupGetBase | None, + Field( + description="Group ID of the support chatbot user or None if no chatbot is defined for this product" + ), + ] = None model_config = ConfigDict( json_schema_extra={ @@ -246,6 +252,12 @@ class MyGroupsGet(OutputSchema): "description": "The support team of the application", "thumbnail": "https://placekitten.com/15/15", }, + "chatbot": { + "gid": "6", + "label": "Chatbot User", + "description": "The chatbot user of the application", + "thumbnail": "https://placekitten.com/15/15", + }, } } ) @@ -256,6 +268,7 @@ def from_domain_model( groups_by_type: GroupsByTypeTuple, my_product_group: tuple[Group, AccessRightsDict] | None, product_support_group: Group | None, + product_chatbot_primary_group: Group | None, ) -> Self: assert groups_by_type.primary # nosec assert groups_by_type.everyone # nosec @@ -278,6 +291,13 @@ def from_domain_model( if product_support_group else None ), + chatbot=( + GroupGetBase.model_validate( + GroupGetBase.dump_basic_group_data(product_chatbot_primary_group) + ) + if product_chatbot_primary_group + else None + ), ) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index a0f91828aafb..7412e2b8df71 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -178,6 +178,7 @@ def from_domain_model( my_product_group: tuple[Group, AccessRightsDict] | None, my_preferences: AggregatedPreferences, my_support_group: Group | None, + my_chatbot_user_group: Group | None, profile_contact: MyProfileAddressGet | None = None, ) -> Self: profile_data = remap_keys( @@ -200,7 +201,10 @@ def from_domain_model( return cls( **profile_data, groups=MyGroupsGet.from_domain_model( - my_groups_by_type, my_product_group, my_support_group + my_groups_by_type, + my_product_group, + my_support_group, + my_chatbot_user_group, ), preferences=my_preferences, contact=profile_contact, diff --git a/packages/models-library/src/models_library/conversations.py b/packages/models-library/src/models_library/conversations.py index 60ec0be59fff..9281fa9b5750 100644 --- a/packages/models-library/src/models_library/conversations.py +++ b/packages/models-library/src/models_library/conversations.py @@ -24,6 +24,10 @@ class ConversationType(StrAutoEnum): auto() # Something like sticky note, can be located anywhere in the pipeline UI ) SUPPORT = auto() # Support conversation + SUPPORT_CALL = auto() # Support call conversation + + def is_support_type(self) -> bool: + return self in {ConversationType.SUPPORT, ConversationType.SUPPORT_CALL} class ConversationMessageType(StrAutoEnum): diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index bc12bfa9ad0d..d60902296ec0 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -77,6 +77,13 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "type": "standard", "thumbnail": None, } + chatbot: JsonDict = { + "gid": 5, + "name": "Chatbot", + "description": "chatbot group", + "type": "primary", + "thumbnail": None, + } schema.update( { @@ -86,6 +93,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: organization, product, support, + chatbot, ] } ) diff --git a/packages/models-library/tests/test_users.py b/packages/models-library/tests/test_users.py index d1e5dbe4efdc..d17599cfcd9a 100644 --- a/packages/models-library/tests/test_users.py +++ b/packages/models-library/tests/test_users.py @@ -8,10 +8,11 @@ from pydantic import TypeAdapter +@pytest.mark.parametrize("with_chatbot_user_group", [True, False]) @pytest.mark.parametrize("with_support_group", [True, False]) @pytest.mark.parametrize("with_standard_groups", [True, False]) def test_adapter_from_model_to_schema( - with_support_group: bool, with_standard_groups: bool + with_support_group: bool, with_standard_groups: bool, with_chatbot_user_group: bool ): my_profile = MyProfile.model_validate(MyProfile.model_json_schema()["example"]) @@ -31,6 +32,7 @@ def test_adapter_from_model_to_schema( ) my_support_group = groups[4] + my_chatbot_user_group = groups[5] my_preferences = {"foo": Preference(default_value=3, value=1)} @@ -40,4 +42,5 @@ def test_adapter_from_model_to_schema( my_product_group, my_preferences, my_support_group if with_support_group else None, + my_chatbot_user_group if with_chatbot_user_group else None, ) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5756d9282a0a_add_support_call_conversationt_ype.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5756d9282a0a_add_support_call_conversationt_ype.py new file mode 100644 index 000000000000..eb3b954d94cf --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5756d9282a0a_add_support_call_conversationt_ype.py @@ -0,0 +1,31 @@ +"""Add SUPPORT_CALL conversationt ype + +Revision ID: 5756d9282a0a +Revises: ff13501db935 +Create Date: 2025-10-21 13:40:20.182151+00:00 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5756d9282a0a" +down_revision = "ff13501db935" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("products", "base_url", existing_type=sa.VARCHAR(), nullable=False) + + op.execute("ALTER TYPE conversationtype ADD VALUE 'SUPPORT_CALL'") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("products", "base_url", existing_type=sa.VARCHAR(), nullable=True) + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/conversations.py b/packages/postgres-database/src/simcore_postgres_database/models/conversations.py index f60271a59860..9b72985cdc8a 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/conversations.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/conversations.py @@ -13,6 +13,7 @@ class ConversationType(enum.Enum): PROJECT_STATIC = "PROJECT_STATIC" # Static conversation for the project PROJECT_ANNOTATION = "PROJECT_ANNOTATION" # Something like sticky note, can be located anywhere in the pipeline UI SUPPORT = "SUPPORT" # Support conversation + SUPPORT_CALL = "SUPPORT_CALL" # Support call conversation conversations = sa.Table( diff --git a/services/director-v2/tests/unit/with_dbs/test_modules_db_repositories_groups_extra_properties.py b/services/director-v2/tests/unit/with_dbs/test_modules_db_repositories_groups_extra_properties.py index 2ac7d6cd0dfb..5642ef3ac098 100644 --- a/services/director-v2/tests/unit/with_dbs/test_modules_db_repositories_groups_extra_properties.py +++ b/services/director-v2/tests/unit/with_dbs/test_modules_db_repositories_groups_extra_properties.py @@ -154,7 +154,12 @@ def _get_group_id(con: sa.engine.Connection) -> int: return result.first()[0] def _insert_product(con: sa.engine.Connection, group_id: int, name: str) -> int: - product_config = {"name": name, "group_id": group_id, "host_regex": ""} + product_config = { + "name": name, + "group_id": group_id, + "host_regex": "", + "base_url": "http://localhost", + } con.execute(products.insert().values(product_config)) def _insert_groups_extra_properties( diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index a876db33a0df..733f42f30538 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -10520,6 +10520,7 @@ components: - PROJECT_STATIC - PROJECT_ANNOTATION - SUPPORT + - SUPPORT_CALL title: ConversationType CountryInfoDict: properties: @@ -13828,6 +13829,12 @@ components: - type: 'null' description: Group ID of the app support team or None if no support is defined for this product + chatbot: + anyOf: + - $ref: '#/components/schemas/GroupGetBase' + - type: 'null' + description: Group ID of the support chatbot user or None if no chatbot + is defined for this product type: object required: - me @@ -13842,6 +13849,11 @@ components: description: Open to all users gid: 1 label: All + chatbot: + description: The chatbot user of the application + gid: '6' + label: Chatbot User + thumbnail: https://placekitten.com/15/15 me: accessRights: delete: true diff --git a/services/web/server/src/simcore_service_webserver/chatbot/_process_chatbot_trigger_service.py b/services/web/server/src/simcore_service_webserver/chatbot/_process_chatbot_trigger_service.py index 0d17c69ebe0b..d2c5a5b826de 100644 --- a/services/web/server/src/simcore_service_webserver/chatbot/_process_chatbot_trigger_service.py +++ b/services/web/server/src/simcore_service_webserver/chatbot/_process_chatbot_trigger_service.py @@ -50,6 +50,7 @@ async def _process_chatbot_trigger_message(app: web.Application, data: bytes) -> limit=20, order_by=OrderBy(field=IDStr("created"), direction=OrderDirection.DESC), ) + _question_for_chatbot = "" for inx, msg in enumerate(messages): if inx == 0: @@ -77,7 +78,7 @@ async def _process_chatbot_trigger_message(app: web.Application, data: bytes) -> type_=ConversationMessageType.MESSAGE, ) except ConversationErrorNotFoundError: - _logger.debug( + _logger.warning( "Can not create a support message as conversation %s was not found", rabbit_message.conversation.conversation_id, ) diff --git a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py index 777c165eb3fe..d07d55d5c254 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py @@ -9,7 +9,6 @@ ConversationMessageID, ConversationMessagePatchDB, ConversationMessageType, - ConversationType, ) from models_library.rest_pagination import ( Page, @@ -72,7 +71,7 @@ async def create_conversation_message(request: web.Request): _conversation = await _conversation_service.get_conversation( request.app, conversation_id=path_params.conversation_id ) - if _conversation.type != ConversationType.SUPPORT: + if _conversation.type.is_support_type() is False: raise_unsupported_type(_conversation.type) # This function takes care of granting support user access to the message @@ -116,7 +115,7 @@ async def list_conversation_messages(request: web.Request): _conversation = await _conversation_service.get_conversation( request.app, conversation_id=path_params.conversation_id ) - if _conversation.type != ConversationType.SUPPORT: + if _conversation.type.is_support_type() is False: raise_unsupported_type(_conversation.type) # This function takes care of granting support user access to the message @@ -170,7 +169,7 @@ async def get_conversation_message(request: web.Request): _conversation = await _conversation_service.get_conversation( request.app, conversation_id=path_params.conversation_id ) - if _conversation.type != ConversationType.SUPPORT: + if _conversation.type.is_support_type() is False: raise_unsupported_type(_conversation.type) # This function takes care of granting support user access to the message @@ -208,7 +207,7 @@ async def update_conversation_message(request: web.Request): _conversation = await _conversation_service.get_conversation( request.app, conversation_id=path_params.conversation_id ) - if _conversation.type != ConversationType.SUPPORT: + if _conversation.type.is_support_type() is False: raise_unsupported_type(_conversation.type) # This function takes care of granting support user access to the message @@ -248,7 +247,7 @@ async def delete_conversation_message(request: web.Request): _conversation = await _conversation_service.get_conversation( request.app, conversation_id=path_params.conversation_id ) - if _conversation.type != ConversationType.SUPPORT: + if _conversation.type.is_support_type() is False: raise_unsupported_type(_conversation.type) # This function takes care of granting support user access to the message diff --git a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_rest.py b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_rest.py index 256a7ca94b90..c4105b9b5224 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_rest.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_rest.py @@ -47,7 +47,7 @@ class _ListConversationsQueryParams(PageQueryParameters): @field_validator("type") @classmethod def validate_type(cls, value): - if value is not None and value != ConversationType.SUPPORT: + if value is not None and value.is_support_type() is False: msg = "Only support type conversations are allowed" raise ValueError(msg) return value @@ -70,7 +70,7 @@ async def create_conversation(request: web.Request): req_ctx = AuthenticatedRequestContext.model_validate(request) body_params = await parse_request_body_as(_ConversationsCreateBodyParams, request) # Ensure only support conversations are allowed - if body_params.type != ConversationType.SUPPORT: + if body_params.type.is_support_type() is False: raise_unsupported_type(body_params.type) _extra_context = body_params.extra_context or {} @@ -101,7 +101,7 @@ async def list_conversations(request: web.Request): query_params = parse_request_query_parameters_as( _ListConversationsQueryParams, request ) - if query_params.type != ConversationType.SUPPORT: + if query_params.type.is_support_type() is False: raise_unsupported_type(query_params.type) total, conversations = ( @@ -146,7 +146,7 @@ async def get_conversation(request: web.Request): conversation = await _conversation_service.get_conversation( request.app, conversation_id=path_params.conversation_id ) - if conversation.type != ConversationType.SUPPORT: + if conversation.type.is_support_type() is False: raise_unsupported_type(conversation.type) conversation, _ = await _conversation_service.get_support_conversation_for_user( @@ -175,7 +175,7 @@ async def update_conversation(request: web.Request): conversation = await _conversation_service.get_conversation( request.app, conversation_id=path_params.conversation_id ) - if conversation.type != ConversationType.SUPPORT: + if conversation.type.is_support_type() is False: raise_unsupported_type(conversation.type) await _conversation_service.get_support_conversation_for_user( @@ -210,7 +210,7 @@ async def delete_conversation(request: web.Request): conversation = await _conversation_service.get_conversation( request.app, conversation_id=path_params.conversation_id ) - if conversation.type != ConversationType.SUPPORT: + if conversation.type.is_support_type() is False: raise_unsupported_type(conversation.type) # Only support conversation creator can delete conversation diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py index 83bf3ef80d20..64b3f8a54ae7 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py @@ -13,6 +13,7 @@ ConversationMessagePatchDB, ConversationMessageType, ConversationPatchDB, + ConversationType, ConversationUserType, ) from models_library.products import ProductName @@ -218,7 +219,7 @@ async def _trigger_chatbot_processing( conversation=conversation, last_message_id=last_message_id, ) - _logger.debug( + _logger.info( "Publishing chatbot processing message with conversation id %s and last message id %s.", conversation.conversation_id, last_message_id, @@ -266,7 +267,7 @@ async def create_support_message( return message if is_first_message or conversation.fogbugz_case_id is None: - _logger.debug( + _logger.info( "Support settings available, this is first message, creating FogBugz case for Conversation ID: %s", conversation.conversation_id, ) @@ -298,7 +299,7 @@ async def create_support_message( ) else: assert not is_first_message # nosec - _logger.debug( + _logger.info( "Support settings available, but this is NOT the first message, so we need to reopen a FogBugz case. Conversation ID: %s", conversation.conversation_id, ) @@ -329,7 +330,8 @@ async def create_support_message( if ( product.support_chatbot_user_id - and conversation_user_type == ConversationUserType.CHATBOT_USER + and conversation.type == ConversationType.SUPPORT + and conversation_user_type == ConversationUserType.REGULAR_USER ): # If enabled, ask Chatbot to analyze the message history and respond await _trigger_chatbot_processing( diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py index 42fbd892d3f9..c7adf4c54daa 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py @@ -51,6 +51,7 @@ async def list_groups(request: web.Request): groups_by_type, my_product_group, product_support_group, + product_chatbot_primary_group, ) = await _groups_service.get_user_profile_groups( request.app, user_id=req_ctx.user_id, product=product ) @@ -59,7 +60,10 @@ async def list_groups(request: web.Request): assert groups_by_type.everyone # nosec my_groups = MyGroupsGet.from_domain_model( - groups_by_type, my_product_group, product_support_group + groups_by_type, + my_product_group, + product_support_group, + product_chatbot_primary_group, ) return envelope_json_response(my_groups) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_service.py b/services/web/server/src/simcore_service_webserver/groups/_groups_service.py index 501adea3df06..8ab349ca819e 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_service.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_service.py @@ -84,6 +84,7 @@ async def get_user_profile_groups( GroupsByTypeTuple, tuple[Group, AccessRightsDict] | None, Group | None, + Group | None, ]: """ Get all groups needed for user profile including standard groups, @@ -110,7 +111,19 @@ async def get_user_profile_groups( app, product.support_standard_group_id ) - return groups_by_type, my_product_group, product_support_group + product_chatbot_primary_group = None + if product.support_chatbot_user_id: + _group_id = await users_service.get_user_primary_group_id( + app, user_id=product.support_chatbot_user_id + ) + product_chatbot_primary_group = await get_group_by_gid(app, _group_id) + + return ( + groups_by_type, + my_product_group, + product_support_group, + product_chatbot_primary_group, + ) # diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index f793de780784..269051405e75 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -56,6 +56,7 @@ async def get_my_profile(request: web.Request) -> web.Response: groups_by_type, my_product_group, product_support_group, + product_chatbot_primary_group, ) = await groups_service.get_user_profile_groups( request.app, user_id=req_ctx.user_id, product=product ) @@ -85,6 +86,7 @@ async def get_my_profile(request: web.Request) -> web.Response: my_product_group, preferences, product_support_group, + product_chatbot_primary_group, my_address, ) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__delete.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__delete.py index 0b8fe41d43ec..3e24ba832aaf 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__delete.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__delete.py @@ -198,7 +198,11 @@ def user_project_in_2_products( ) -> Iterator[ProjectDict]: fake_product_name = faker.name() with postgres_db.connect() as conn: - conn.execute(products.insert().values(name=fake_product_name, host_regex="")) + conn.execute( + products.insert().values( + name=fake_product_name, host_regex="", base_url="http://localhost" + ) + ) conn.execute( projects_to_products.insert().values( project_uuid=user_project["uuid"], product_name=fake_product_name