From 8029741d36f0f266cf7b0cae466602f26a85524a Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 24 Jul 2025 15:55:59 -0700 Subject: [PATCH 1/2] Allows changing and tracking changes of titles. Updates sharable conversation's title whenever the coordinator's conversation title changes. --- .../assistant/assistant.py | 98 ++++++++++--------- .../assistant/common.py | 57 +++++++++++ .../assistant_app/context.py | 11 +++ .../assistant_app/service.py | 7 ++ 4 files changed, 126 insertions(+), 47 deletions(-) diff --git a/assistants/knowledge-transfer-assistant/assistant/assistant.py b/assistants/knowledge-transfer-assistant/assistant/assistant.py index 400ebc22..d91bbe50 100644 --- a/assistants/knowledge-transfer-assistant/assistant/assistant.py +++ b/assistants/knowledge-transfer-assistant/assistant/assistant.py @@ -4,7 +4,6 @@ import asyncio import pathlib -from enum import Enum from typing import Any from assistant_extensions import attachments, dashboard_card, navigator @@ -34,7 +33,7 @@ load_text_include, ) -from .common import detect_assistant_role +from .common import detect_assistant_role, detect_conversation_type, get_shared_conversation_id, ConversationType from .config import assistant_config from .conversation_share_link import ConversationKnowledgePackageManager from .data import InspectorTab, LogEntryType @@ -98,13 +97,6 @@ async def content_evaluator_factory( app = assistant.fastapi_app() - -class ConversationType(Enum): - COORDINATOR = "coordinator" - TEAM = "team" - SHAREABLE_TEMPLATE = "shareable_template" - - @assistant.events.conversation.on_created_including_mine async def on_conversation_created(context: ConversationContext) -> None: """ @@ -113,50 +105,26 @@ async def on_conversation_created(context: ConversationContext) -> None: 2. Shareable Team Conversation: A template conversation that has a share URL and is never directly used 3. Team Conversation(s): Individual conversations for team members created when they redeem the share URL """ - # Get conversation to access metadata + conversation = await context.get_conversation() conversation_metadata = conversation.metadata or {} + share_id = conversation_metadata.get("share_id") config = await assistant_config.get(context.assistant) + conversation_type = detect_conversation_type(conversation) - ## - ## Figure out what type of conversation this is. - ## - - conversation_type = ConversationType.COORDINATOR - - # Coordinator conversations will not have a share_id or - # is_team_conversation flag in the metadata. So, if they are there, we just - # need to decide if it's a shareable template or a team conversation. - share_id = conversation_metadata.get("share_id") - if conversation_metadata.get("is_team_conversation", False) and share_id: - # If this conversation was imported from another, it indicates it's from - # share redemption. - if conversation.imported_from_conversation_id: - conversation_type = ConversationType.TEAM - # TODO: This might work better for detecting a redeemed link, but - # hasn't been validated. - - # if conversation_metadata.get("share_redemption") and conversation_metadata.get("share_redemption").get( - # "conversation_share_id" - # ): - # conversation_type = ConversationType.TEAM - else: - conversation_type = ConversationType.SHAREABLE_TEMPLATE - - ## - ## Handle the conversation based on its type - ## match conversation_type: case ConversationType.SHAREABLE_TEMPLATE: + + # Associate the shareable template with a share ID if not share_id: logger.error("No share ID found for shareable team conversation.") return - await ConversationKnowledgePackageManager.associate_conversation_with_share(context, share_id) return case ConversationType.TEAM: + if not share_id: logger.error("No share ID found for team conversation.") return @@ -170,13 +138,9 @@ async def on_conversation_created(context: ConversationContext) -> None: ) await ConversationKnowledgePackageManager.associate_conversation_with_share(context, share_id) - # Set the conversation role for team conversations await ConversationKnowledgePackageManager.set_conversation_role(context, share_id, ConversationRole.TEAM) - - # Synchronize files. await ShareManager.synchronize_files_to_team_conversation(context=context, share_id=share_id) - # Generate a welcome message. welcome_message, debug = await generate_team_welcome_message(context) await context.send_messages( NewConversationMessage( @@ -202,11 +166,10 @@ async def on_conversation_created(context: ConversationContext) -> None: case ConversationType.COORDINATOR: try: + # In the beginning, we created a share... share_id = await KnowledgeTransferManager.create_share(context) - # No default brief - let the state inspector handle displaying instructional content - - # Create a team conversation with a share URL + # And it was good. So we then created a sharable conversation that we use as a template. share_url = await KnowledgeTransferManager.create_shareable_team_conversation( context=context, share_id=share_id ) @@ -218,7 +181,6 @@ async def on_conversation_created(context: ConversationContext) -> None: except Exception as e: welcome_message = f"I'm having trouble setting up your knowledge transfer. Please try again or contact support if the issue persists. {str(e)}" - # Send the welcome message await context.send_messages( NewConversationMessage( content=welcome_message, @@ -226,6 +188,45 @@ async def on_conversation_created(context: ConversationContext) -> None: ) ) + # Pop open the inspector panel. + await context.send_conversation_state_event( + AssistantStateEvent( + state_id="brief", + event="focus", + state=None, + ) + ) + +@assistant.events.conversation.on_updated +async def on_conversation_updated(context: ConversationContext) -> None: + """ + Handle conversation updates (including title changes) and sync with shareable template. + """ + try: + conversation = await context.get_conversation() + conversation_type = detect_conversation_type(conversation) + if conversation_type != ConversationType.COORDINATOR: + return + + shared_conversation_id = await get_shared_conversation_id(context) + if not shared_conversation_id: + return + + # Update the shareable template conversation's title if needed. + try: + target_context = context.for_conversation(shared_conversation_id) + target_conversation = await target_context.get_conversation() + if target_conversation.title != conversation.title: + await target_context.update_conversation_title(conversation.title) + logger.debug(f"Updated conversation {shared_conversation_id} title from '{target_conversation.title}' to '{conversation.title}'") + else: + logger.debug(f"Conversation {shared_conversation_id} title already matches: '{conversation.title}'") + except Exception as title_update_error: + logger.error(f"Error updating conversation {shared_conversation_id} title: {title_update_error}") + + except Exception as e: + logger.error(f"Error syncing conversation title: {e}") + @assistant.events.conversation.message.chat.on_created async def on_message_created( @@ -545,3 +546,6 @@ async def on_participant_joined( except Exception as e: logger.exception(f"Error handling participant join event: {e}") + + + diff --git a/assistants/knowledge-transfer-assistant/assistant/common.py b/assistants/knowledge-transfer-assistant/assistant/common.py index 1b2ded28..72a67ee9 100644 --- a/assistants/knowledge-transfer-assistant/assistant/common.py +++ b/assistants/knowledge-transfer-assistant/assistant/common.py @@ -5,6 +5,7 @@ helping to reduce code duplication and maintain consistency. """ +from enum import Enum from typing import Dict, Optional from semantic_workbench_assistant.assistant_app import ConversationContext @@ -14,7 +15,35 @@ from .logging import logger from .storage import ShareStorage from .storage_models import ConversationRole +from semantic_workbench_api_model.workbench_model import Conversation +class ConversationType(Enum): + COORDINATOR = "coordinator" + TEAM = "team" + SHAREABLE_TEMPLATE = "shareable_template" + +def detect_conversation_type(conversation: Conversation) -> ConversationType: + conversation_metadata = conversation.metadata or {} + conversation_type = ConversationType.COORDINATOR + # Coordinator conversations will not have a share_id or + # is_team_conversation flag in the metadata. So, if they are there, we just + # need to decide if it's a shareable template or a team conversation. + share_id = conversation_metadata.get("share_id") + if conversation_metadata.get("is_team_conversation", False) and share_id: + # If this conversation was imported from another, it indicates it's from + # share redemption. + if conversation.imported_from_conversation_id: + conversation_type = ConversationType.TEAM + # TODO: This might work better for detecting a redeemed link, but + # hasn't been validated. + + # if conversation_metadata.get("share_redemption") and conversation_metadata.get("share_redemption").get( + # "conversation_share_id" + # ): + # conversation_type = ConversationType.TEAM + else: + conversation_type = ConversationType.SHAREABLE_TEMPLATE + return conversation_type async def detect_assistant_role(context: ConversationContext) -> ConversationRole: """ @@ -45,6 +74,34 @@ async def detect_assistant_role(context: ConversationContext) -> ConversationRol return ConversationRole.COORDINATOR +async def get_shared_conversation_id(context: ConversationContext) -> Optional[str]: + """ + Get the shared conversation ID for a coordinator conversation. + + This utility function retrieves the share ID and finds the associated + shareable template conversation ID from the knowledge package. + + Args: + context: The conversation context (should be a coordinator conversation) + + Returns: + The shared conversation ID if found, None otherwise + """ + try: + share_id = await ConversationKnowledgePackageManager.get_associated_share_id(context) + if not share_id: + return None + + knowledge_package = ShareStorage.read_share(share_id) + if not knowledge_package or not knowledge_package.shared_conversation_id: + return None + + return knowledge_package.shared_conversation_id + except Exception as e: + logger.error(f"Error getting shared conversation ID: {e}") + return None + + async def log_transfer_action( context: ConversationContext, entry_type: LogEntryType, diff --git a/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py b/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py index c710a393..05b18dd6 100644 --- a/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py +++ b/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py @@ -128,6 +128,17 @@ async def get_conversation(self) -> workbench_model.Conversation: async def update_conversation(self, metadata: dict[str, Any]) -> workbench_model.Conversation: return await self._conversation_client.update_conversation(metadata) + async def update_conversation_title(self, title: str) -> workbench_model.Conversation: + """Update the conversation's title.""" + update_data = workbench_model.UpdateConversation(title=title) + http_response = await self._conversation_client._client.patch( + f"/conversations/{self.id}", + json=update_data.model_dump(mode="json", exclude_unset=True, exclude_defaults=True), + headers=self._conversation_client._headers, + ) + http_response.raise_for_status() + return workbench_model.Conversation.model_validate(http_response.json()) + async def get_participants(self, include_inactive=False) -> workbench_model.ConversationParticipantList: return await self._conversation_client.get_participants(include_inactive=include_inactive) diff --git a/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/service.py b/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/service.py index bd29095a..b0c25e12 100644 --- a/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/service.py +++ b/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/service.py @@ -696,6 +696,13 @@ async def _forward_event( file, ) + case workbench_model.ConversationEventType.conversation_updated: + # Conversation metadata updates (title, metadata, etc.) + await self.assistant_app.events.conversation._on_updated_handlers( + True, # event_originated_externally (always True for workbench updates) + conversation_context, + ) + @translate_assistant_errors async def get_conversation_state_descriptions( self, assistant_id: str, conversation_id: str From 14079f61e748dfc607e182a606c6860af824d21c Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Fri, 25 Jul 2025 11:34:08 -0700 Subject: [PATCH 2/2] Implement conversation title update method in ConversationAPIClient and refactor context to use it --- .../workbench_service_client.py | 10 ++++++++++ .../assistant_app/context.py | 10 +--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py b/libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py index 6c392405..7c59d51e 100644 --- a/libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py +++ b/libraries/python/semantic-workbench-api-model/semantic_workbench_api_model/workbench_service_client.py @@ -143,6 +143,16 @@ async def update_conversation(self, metadata: dict[str, Any]) -> workbench_model http_response.raise_for_status() return workbench_model.Conversation.model_validate(http_response.json()) + async def update_conversation_title(self, title: str) -> workbench_model.Conversation: + update_data = workbench_model.UpdateConversation(title=title) + http_response = await self._client.patch( + f"/conversations/{self._conversation_id}", + json=update_data.model_dump(mode="json", exclude_unset=True, exclude_defaults=True), + headers=self._headers, + ) + http_response.raise_for_status() + return workbench_model.Conversation.model_validate(http_response.json()) + async def get_participant_me(self) -> workbench_model.ConversationParticipant: http_response = await self._client.get( f"/conversations/{self._conversation_id}/participants/me", headers=self._headers diff --git a/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py b/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py index 05b18dd6..78a6952c 100644 --- a/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py +++ b/libraries/python/semantic-workbench-assistant/semantic_workbench_assistant/assistant_app/context.py @@ -129,15 +129,7 @@ async def update_conversation(self, metadata: dict[str, Any]) -> workbench_model return await self._conversation_client.update_conversation(metadata) async def update_conversation_title(self, title: str) -> workbench_model.Conversation: - """Update the conversation's title.""" - update_data = workbench_model.UpdateConversation(title=title) - http_response = await self._conversation_client._client.patch( - f"/conversations/{self.id}", - json=update_data.model_dump(mode="json", exclude_unset=True, exclude_defaults=True), - headers=self._conversation_client._headers, - ) - http_response.raise_for_status() - return workbench_model.Conversation.model_validate(http_response.json()) + return await self._conversation_client.update_conversation_title(title) async def get_participants(self, include_inactive=False) -> workbench_model.ConversationParticipantList: return await self._conversation_client.get_participants(include_inactive=include_inactive)